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..fd3038656 --- /dev/null +++ b/app/(landing)/layout.tsx @@ -0,0 +1,43 @@ +import { Metadata } from 'next'; +import { ReactNode } from 'react'; +import { Navbar } 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/(landing)/waitlist/page.tsx b/app/(landing)/waitlist/page.tsx new file mode 100644 index 000000000..4d5d53dc9 --- /dev/null +++ b/app/(landing)/waitlist/page.tsx @@ -0,0 +1,392 @@ +'use client'; +import React, { useRef, useState } from 'react'; +import gsap from 'gsap'; +import { useGSAP } from '@gsap/react'; +import { SplitText } from 'gsap/SplitText'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import z from 'zod'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import { ArrowRight, Mail, User } from 'lucide-react'; +import Link from 'next/link'; +import { cn } from '@/lib/utils'; + +const formSchema = z.object({ + email: z.string().email('Please enter a valid email'), + name: z.string().min(2, 'Name must be at least 2 characters'), +}); + +const WaitlistPage = () => { + gsap.registerPlugin(useGSAP, SplitText); + const container = useRef(null); + const titleRef = useRef(null); + const subtitleRef = useRef(null); + const [titleAnimation, setTitleAnimation] = useState(null); + const [titleSplit, setTitleSplit] = useState(null); + const [subtitleAnimation, setSubtitleAnimation] = useState(null); + const [subtitleSplit, setSubtitleSplit] = useState(null); + const [isSubmitted, setIsSubmitted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const tl = gsap.timeline(); + + const formRef = useRef(null); + const nameFieldRef = useRef(null); + const emailFieldRef = useRef(null); + const buttonRef = useRef(null); + const successRef = useRef(null); + + useGSAP( + () => { + if (titleRef.current) { + const split = SplitText.create(titleRef.current, { + type: 'chars,words,lines', + }); + + split.chars.forEach((char: Element) => { + (char as HTMLElement).style.background = + 'linear-gradient(180deg, #69726D 0%, #FFF 100%)'; + (char as HTMLElement).style.webkitBackgroundClip = 'text'; + (char as HTMLElement).style.backgroundClip = 'text'; + (char as HTMLElement).style.webkitTextFillColor = 'transparent'; + (char as HTMLElement).style.color = 'transparent'; + }); + + setTitleSplit(split); + + const animation = tl + .from(split.chars, { + x: 150, + opacity: 0, + duration: 1, + ease: 'power4', + stagger: 0.04, + }) + .to( + split.words, + { + duration: 0.6, + scale: 0.9, + stagger: 0.1, + }, + 'words' + ) + .to( + split.words, + { + duration: 0.8, + scale: 1, + stagger: 0.1, + }, + 'words+=0.1' + ); + + setTitleAnimation(animation); + } + + if (subtitleRef.current) { + const split = SplitText.create(subtitleRef.current, { type: 'words' }); + setSubtitleSplit(split); + split.words.forEach((word: Element) => { + (word as HTMLElement).style.background = + 'linear-gradient(273deg, rgba(167, 249, 80, 0.50) 13.84%, #3AE6B2 73.28%)'; + (word as HTMLElement).style.webkitBackgroundClip = 'text'; + (word as HTMLElement).style.backgroundClip = 'text'; + (word as HTMLElement).style.webkitTextFillColor = 'transparent'; + (word as HTMLElement).style.color = 'transparent'; + }); + + const animation = gsap.from(split.words, { + y: -100, + opacity: 0, + rotation: 'random(-80, 80)', + delay: 0.9, + duration: 0.7, + ease: 'back', + stagger: 0.15, + }); + setSubtitleAnimation(animation); + } + + if (formRef.current) { + gsap.fromTo( + formRef.current, + { opacity: 0, y: 50, scale: 0.95 }, + { + opacity: 1, + y: 0, + scale: 1, + duration: 0.8, + delay: 1.5, + ease: 'back.out(1.7)', + } + ); + } + }, + { scope: container } + ); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: '', + name: '', + }, + }); + + const animateFieldFocus = ( + fieldRef: React.RefObject + ) => { + if (fieldRef.current) { + gsap.to(fieldRef.current, { + duration: 0.3, + scale: 1.02, + boxShadow: '0 0 0 1px rgb(167,249,80)', + ease: 'power2.out', + }); + } + }; + + const animateFieldBlur = ( + fieldRef: React.RefObject + ) => { + if (fieldRef.current) { + gsap.to(fieldRef.current, { + duration: 0.3, + scale: 1, + boxShadow: 'none', + ease: 'power2.out', + }); + } + }; + + const animateFormSubmission = () => { + setIsSubmitting(true); + + const tl = gsap.timeline(); + + if (buttonRef.current) { + tl.to(buttonRef.current, { + duration: 0.3, + scale: 0.95, + ease: 'power2.in', + }); + } + + setTimeout(() => { + setIsSubmitting(false); + setIsSubmitted(true); + + if (formRef.current && successRef.current) { + const tl = gsap.timeline(); + tl.to(formRef.current, { + duration: 0.5, + opacity: 0, + y: -30, + scale: 0.95, + ease: 'power2.in', + }).to( + successRef.current, + { + duration: 0.6, + opacity: 1, + y: 0, + scale: 1, + ease: 'back.out(1.7)', + }, + '-=0.3' + ); + } + }, 2000); + }; + + const onSubmit = (values: z.infer) => { + console.log(values); + animateFormSubmission(); + }; + + const handleTitleClick = () => { + if (titleAnimation && titleSplit) { + titleAnimation.revert(); + const newAnimation = tl.from(titleSplit.chars, { + x: 150, + opacity: 0, + duration: 0.7, + ease: 'power4', + stagger: 0.04, + }); + setTitleAnimation(newAnimation); + } + }; + + const handleSubtitleClick = () => { + if (subtitleAnimation && subtitleSplit) { + subtitleAnimation.revert(); + const newAnimation = gsap.from(subtitleSplit.words, { + y: -100, + opacity: 0, + rotation: 'random(-80, 80)', + duration: 0.7, + ease: 'back', + stagger: 0.15, + }); + setSubtitleAnimation(newAnimation); + } + }; + + return ( +
+
+

+ Get Early Access to +

+ +

+ Boundless +

+
+ {!isSubmitted ? ( +
+
+ + ( + + Name + +
animateFieldFocus(nameFieldRef)} + onBlur={() => animateFieldBlur(nameFieldRef)} + > + + +
+
+ +
+ )} + /> + ( + + Email + +
animateFieldFocus(emailFieldRef)} + onBlur={() => animateFieldBlur(emailFieldRef)} + > + + +
+
+ +
+ )} + /> + + {isSubmitting ? 'Submitting...' : 'Join the waitlist'}{' '} + + + + +
+ ) : ( +
+
+ + + + + +

+ You have been added to the waitlist +

+

+ We'll let you know when Boundless is ready +

+
+
+ )} +
+

+ By joining, you agree to receive updates from Boundless. Learn more in + our{' '} + + Privacy policy + + . +

+
+
+ ); +}; + +export default WaitlistPage; diff --git a/app/globals.css b/app/globals.css index e57eb72d1..5c742704f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -186,3 +186,50 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; } +.text-gradient { + background-image: linear-gradient(180deg, #69726d 0%, #fff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +/* Dropdown menu item animations */ +@keyframes dropdownItemSlideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* SplitText character animations for mobile menu */ +.char { + display: inline-block; + will-change: transform; +} + +.word { + display: inline-block; + margin-right: 0.25em; +} + +/* Mobile menu animation enhancements */ +.mobile-menu-enter { + opacity: 0; + transform: translateY(-20px); +} + +.mobile-menu-enter-active { + opacity: 1; + transform: translateY(0); + transition: all 0.5s ease-out; +} + +/* Navbar character animations */ +.nav-char { + display: inline-block; + will-change: transform; + transition: transform 0.2s ease-out; +} 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/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 f86796acb..b82b525a0 100644 --- a/components/Projects.tsx +++ b/components/Projects.tsx @@ -15,6 +15,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu'; +import Pagination from './ui/pagination'; import { getProjects } from '@/lib/api/project'; import { toast } from 'sonner'; @@ -38,9 +39,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,48 +57,77 @@ const Projects = () => { { value: 'completed', label: 'Completed' }, ]; - const fetchProjects = useCallback(async () => { - try { - setLoading(true); - setError(null); - const response = await getProjects(); + const fetchProjects = useCallback( + async (pageNum = 1) => { + try { + setLoading(true); + setError(null); + + // 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 transformedProjects = (response.projects || []).map( - (project: any) => ({ - id: project._id, - name: project.title, - description: project.description, - image: project.whitepaperUrl || '/banner.png', - link: `/projects/${project._id}`, - tags: project.tags || [], - category: project.category, - type: project.type, - amount: 0, - status: project.status, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - // Add owner information for filtering - owner: project.owner?.type?._id || null, - ownerName: project.owner?.type?.profile - ? `${project.owner.type.profile.firstName} ${project.owner.type.profile.lastName}` - : 'Anonymous', - ownerUsername: project.owner?.type?.profile?.username || 'anonymous', - ownerAvatar: project.owner?.type?.profile?.avatar || '', - }) - ); + const response = await getProjects(pageNum, ITEMS_PER_PAGE, filters); - setProjects(transformedProjects); - } catch { - setError('Failed to fetch projects'); - toast.error('Failed to fetch projects'); - } finally { - setLoading(false); - } - }, []); + const transformedProjects = (response.projects || []).map( + (project: any) => ({ + id: project._id, + name: project.title, + description: project.description, + image: project.whitepaperUrl || '/banner.png', + link: `/projects/${project._id}`, + tags: project.tags || [], + category: project.category, + type: project.type, + amount: 0, + status: project.status, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + // Add owner information for filtering + owner: project.owner?.type?._id || null, + ownerName: project.owner?.type?.profile + ? `${project.owner.type.profile.firstName} ${project.owner.type.profile.lastName}` + : 'Anonymous', + ownerUsername: + project.owner?.type?.profile?.username || 'anonymous', + ownerAvatar: project.owner?.type?.profile?.avatar || '', + }) + ); + + setProjects(transformedProjects); + + // Update pagination state + if (response.pagination) { + setTotalPages(response.pagination.totalPages || 1); + setTotalItems(response.pagination.totalItems || 0); + } + } catch { + setError('Failed to fetch projects'); + toast.error('Failed to fetch projects'); + } finally { + setLoading(false); + } + }, + [isAuthenticated, statusFilter, tabFilter, user] + ); useEffect(() => { - fetchProjects(); - }, [fetchProjects]); + setCurrentPage(1); + fetchProjects(1); + }, [statusFilter, tabFilter, isAuthenticated, user?.id, fetchProjects]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + fetchProjects(page); + }; if (loading) { return ( @@ -119,28 +154,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' + > + + - Active + All + + + Funding + + + Funded @@ -199,7 +257,7 @@ const Projects = () => {

{error}

- - {/* 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/landing-page/footer.tsx b/components/landing-page/footer.tsx new file mode 100644 index 000000000..a7caf2b04 --- /dev/null +++ b/components/landing-page/footer.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link'; + +export function Footer() { + const currentYear = new Date().getFullYear(); + + return ( +
+
+
+ {/* Copyright */} +
+ © {currentYear} Boundless. All rights reserved. +
+ + {/* Links */} +
+ + Terms + + + Privacy + + + Code of Conduct + +
+
+
+
+ ); +} diff --git a/components/landing-page/index.ts b/components/landing-page/index.ts new file mode 100644 index 000000000..72a8e6247 --- /dev/null +++ b/components/landing-page/index.ts @@ -0,0 +1,2 @@ +export { Navbar } from './navbar'; +export { Footer } from './footer'; diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx new file mode 100644 index 000000000..0116c4080 --- /dev/null +++ b/components/landing-page/navbar.tsx @@ -0,0 +1,450 @@ +'use client'; +import Link from 'next/link'; +import { ChevronDown, Menu, XIcon } from 'lucide-react'; +import Image from 'next/image'; +import { useRef } from 'react'; +import { useGSAP } from '@gsap/react'; +import gsap from 'gsap'; +import { SplitText } from 'gsap/SplitText'; +import { BoundlessButton } from '../buttons'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Sheet, SheetTrigger, SheetContent, SheetClose } from '../ui/sheet'; + +gsap.registerPlugin(useGSAP, SplitText); + +const menuItems = [ + { href: '/projects', label: 'Projects' }, + { href: '/hackathons', label: 'Hackathons' }, + { href: '/grants', label: 'Grants' }, +]; + +const learnMenuItems = [ + { href: '/about', label: 'About' }, + { href: '/code-of-conduct', label: 'Code of Conduct' }, + { href: '/privacy', label: 'Privacy Policy' }, + { href: '/terms', label: 'Terms of Service' }, + { href: '/disclaimer', label: 'Disclaimer' }, +]; + +export function Navbar() { + const navbarRef = useRef(null); + const logoRef = useRef(null); + const menuRef = useRef(null); + const ctaRef = useRef(null); + + useGSAP( + () => { + const tl = gsap.timeline({ defaults: { ease: 'power2.out' } }); + + tl.fromTo( + navbarRef.current, + { y: -20, opacity: 0 }, + { y: 0, opacity: 1, duration: 0.6 } + ) + .fromTo( + logoRef.current, + { scale: 0.8, opacity: 0 }, + { scale: 1, opacity: 1, duration: 0.4 }, + '-=0.3' + ) + .fromTo( + menuRef.current?.children || [], + { y: -15, opacity: 0 }, + { y: 0, opacity: 1, duration: 0.4, stagger: 0.1 }, + '-=0.2' + ) + .fromTo( + ctaRef.current, + { y: -15, opacity: 0, scale: 0.95 }, + { y: 0, opacity: 1, scale: 1, duration: 0.4 }, + '-=0.1' + ); + + gsap.to(logoRef.current, { + y: -2, + duration: 2, + ease: 'power2.inOut', + yoyo: true, + repeat: -1, + }); + + const logoHover = gsap.to(logoRef.current, { + scale: 1.05, + duration: 0.2, + ease: 'power2.out', + paused: true, + }); + + const logoEnterHandler = () => logoHover.play(); + const logoLeaveHandler = () => logoHover.reverse(); + + logoRef.current?.addEventListener('mouseenter', logoEnterHandler); + logoRef.current?.addEventListener('mouseleave', logoLeaveHandler); + + const menuItems = menuRef.current?.querySelectorAll('a'); + const menuItemAnimations: Array<{ + item: Element; + hoverTl: gsap.core.Timeline; + enterHandler: () => void; + leaveHandler: () => void; + split: any; + }> = []; + + menuItems?.forEach(item => { + const split = SplitText.create(item, { + type: 'chars', + charsClass: 'nav-char', + }); + + const hoverTl = gsap.timeline({ paused: true }); + hoverTl.to(item, { y: -2, duration: 0.2, ease: 'power2.out' }); + + hoverTl.to( + split.chars, + { + y: -3, + duration: 0.3, + stagger: 0.02, + ease: 'power2.out', + }, + '-=0.1' + ); + + const enterHandler = () => hoverTl.play(); + const leaveHandler = () => hoverTl.reverse(); + + item.addEventListener('mouseenter', enterHandler); + item.addEventListener('mouseleave', leaveHandler); + + menuItemAnimations.push({ + item, + hoverTl, + enterHandler, + leaveHandler, + split, + }); + }); + + const ctaHover = gsap.to(ctaRef.current, { + scale: 1.02, + duration: 0.2, + ease: 'power2.out', + paused: true, + }); + + const ctaEnterHandler = () => ctaHover.play(); + const ctaLeaveHandler = () => ctaHover.reverse(); + + ctaRef.current?.addEventListener('mouseenter', ctaEnterHandler); + ctaRef.current?.addEventListener('mouseleave', ctaLeaveHandler); + + const scrollTl = gsap.timeline({ paused: true }); + scrollTl.to(navbarRef.current, { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + backdropFilter: 'blur(10px)', + duration: 0.3, + ease: 'power2.out', + }); + + const handleScroll = () => { + if (window.scrollY > 50) { + scrollTl.play(); + } else { + scrollTl.reverse(); + } + }; + + window.addEventListener('scroll', handleScroll); + + return () => { + logoHover.kill(); + ctaHover.kill(); + scrollTl.kill(); + + logoRef.current?.removeEventListener('mouseenter', logoEnterHandler); + logoRef.current?.removeEventListener('mouseleave', logoLeaveHandler); + + menuItemAnimations.forEach( + ({ item, hoverTl, enterHandler, leaveHandler, split }) => { + hoverTl.kill(); + split.revert(); + item.removeEventListener('mouseenter', enterHandler); + item.removeEventListener('mouseleave', leaveHandler); + } + ); + + ctaRef.current?.removeEventListener('mouseenter', ctaEnterHandler); + ctaRef.current?.removeEventListener('mouseleave', ctaLeaveHandler); + + window.removeEventListener('scroll', handleScroll); + }; + }, + { scope: navbarRef } + ); + + return ( + + ); +} + +function MobileMenu() { + const mobileMenuRef = useRef(null); + const mobileButtonRef = useRef(null); + const mobileLogoRef = useRef(null); + const mobileMenuItemsRef = useRef(null); + const mobileCTARef = useRef(null); + + useGSAP( + () => { + gsap.fromTo( + mobileButtonRef.current, + { scale: 0.8, opacity: 0 }, + { scale: 1, opacity: 1, duration: 0.4, delay: 0.8 } + ); + + const buttonHover = gsap.to(mobileButtonRef.current, { + scale: 1.05, + duration: 0.2, + ease: 'power2.out', + paused: true, + }); + + const buttonEnterHandler = () => buttonHover.play(); + const buttonLeaveHandler = () => buttonHover.reverse(); + + mobileButtonRef.current?.addEventListener( + 'mouseenter', + buttonEnterHandler + ); + mobileButtonRef.current?.addEventListener( + 'mouseleave', + buttonLeaveHandler + ); + + return () => { + buttonHover.kill(); + mobileButtonRef.current?.removeEventListener( + 'mouseenter', + buttonEnterHandler + ); + mobileButtonRef.current?.removeEventListener( + 'mouseleave', + buttonLeaveHandler + ); + }; + }, + { scope: mobileMenuRef } + ); + + const animateMobileMenuOpen = () => { + gsap.fromTo( + mobileLogoRef.current, + { x: -20, opacity: 0 }, + { x: 0, opacity: 1, duration: 0.5, ease: 'power2.out' } + ); + + const menuItems = mobileMenuItemsRef.current?.querySelectorAll('a'); + if (menuItems) { + menuItems.forEach((item, index) => { + const split = SplitText.create(item, { + type: 'chars, words', + charsClass: 'char', + wordsClass: 'word', + }); + + gsap.fromTo( + split.words, + { + y: 20, + opacity: 0, + rotationX: -90, + transformOrigin: '50% 50% -20px', + }, + { + y: 0, + opacity: 1, + rotationX: 0, + duration: 0.6, + stagger: 0.03, + delay: 0.2 + index * 0.1, + ease: 'back.out(1.7)', + } + ); + }); + } + + gsap.fromTo( + mobileCTARef.current, + { y: 30, opacity: 0, scale: 0.9 }, + { + y: 0, + opacity: 1, + scale: 1, + duration: 0.5, + delay: 0.8, + ease: 'back.out(1.7)', + } + ); + }; + + const animateMobileMenuClose = () => { + gsap.to( + [mobileLogoRef.current, mobileMenuItemsRef.current, mobileCTARef.current], + { + opacity: 0, + y: -20, + duration: 0.3, + ease: 'power2.in', + } + ); + }; + + return ( +
+ { + if (open) { + setTimeout(animateMobileMenuOpen, 100); + } else { + animateMobileMenuClose(); + } + }} + > + + + + + + +
+
+ + logo + +
+ + + + + +
+
+ {menuItems.map(item => ( + + {item.label} + + ))} + + How it works + + + About us + + + Blog + +
+
+ + Get Started + +
+
+ +
+ ); +} diff --git a/components/overview/RecentProjects.tsx b/components/overview/RecentProjects.tsx index d0e2b7cff..d87108079 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' @@ -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 ( -