From 803979cfd899b6622fd9b02279cf3288d26847ac Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Wed, 18 Mar 2026 15:05:42 +0100 Subject: [PATCH 1/6] feat: Implement a comprehensive project and campaign creation flow, including dedicated public campaign pages. --- .../campaigns/[slug]/contributions/page.tsx | 92 +++ .../[slug]/milestone/[milestoneId]/page.tsx | 140 ++++ app/(landing)/campaigns/[slug]/page.tsx | 398 ++++++++++++ app/(landing)/campaigns/layout.tsx | 12 + app/(landing)/campaigns/page.tsx | 16 + app/(landing)/projects/page.tsx | 8 +- app/me/projects/create/page.tsx | 2 +- app/projects/create/page.tsx | 18 + components/landing-page/Explore.tsx | 10 +- components/landing-page/navbar.tsx | 42 +- .../projects/components/CampaignPageHero.tsx | 71 +++ .../CreationFlow/CreationLayout.tsx | 248 +++++++ .../CreationFlow/CreationNavigation.tsx | 105 +++ .../CreationFlow/CreationSidebar.tsx | 201 ++++++ .../components/CreationFlow/CreationUI.tsx | 603 ++++++++++++++++++ .../CreationFlow/Steps/BasicInfo.tsx | 531 +++++++++++++++ .../CreationFlow/Steps/CampaignDetails.tsx | 463 ++++++++++++++ .../CreationFlow/Steps/ProjectDetails.tsx | 74 +++ .../CreationFlow/Steps/ReviewStep.tsx | 218 +++++++ .../CreationFlow/Steps/SocialLinks.tsx | 102 +++ .../CreationFlow/Steps/TeamInfo.tsx | 160 +++++ .../components/GenericProjectHero.tsx | 53 ++ .../components/GenericProjectsClient.tsx | 25 + features/projects/components/ProjectsPage.tsx | 8 +- .../projects/hooks/use-project-creation.ts | 194 ++++++ 25 files changed, 3760 insertions(+), 34 deletions(-) create mode 100644 app/(landing)/campaigns/[slug]/contributions/page.tsx create mode 100644 app/(landing)/campaigns/[slug]/milestone/[milestoneId]/page.tsx create mode 100644 app/(landing)/campaigns/[slug]/page.tsx create mode 100644 app/(landing)/campaigns/layout.tsx create mode 100644 app/(landing)/campaigns/page.tsx create mode 100644 app/projects/create/page.tsx create mode 100644 features/projects/components/CampaignPageHero.tsx create mode 100644 features/projects/components/CreationFlow/CreationLayout.tsx create mode 100644 features/projects/components/CreationFlow/CreationNavigation.tsx create mode 100644 features/projects/components/CreationFlow/CreationSidebar.tsx create mode 100644 features/projects/components/CreationFlow/CreationUI.tsx create mode 100644 features/projects/components/CreationFlow/Steps/BasicInfo.tsx create mode 100644 features/projects/components/CreationFlow/Steps/CampaignDetails.tsx create mode 100644 features/projects/components/CreationFlow/Steps/ProjectDetails.tsx create mode 100644 features/projects/components/CreationFlow/Steps/ReviewStep.tsx create mode 100644 features/projects/components/CreationFlow/Steps/SocialLinks.tsx create mode 100644 features/projects/components/CreationFlow/Steps/TeamInfo.tsx create mode 100644 features/projects/components/GenericProjectHero.tsx create mode 100644 features/projects/components/GenericProjectsClient.tsx create mode 100644 features/projects/hooks/use-project-creation.ts diff --git a/app/(landing)/campaigns/[slug]/contributions/page.tsx b/app/(landing)/campaigns/[slug]/contributions/page.tsx new file mode 100644 index 00000000..f1c7f7fe --- /dev/null +++ b/app/(landing)/campaigns/[slug]/contributions/page.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { getCrowdfundingProject } from '@/features/projects/api'; +import type { Crowdfunding } from '@/features/projects/types'; +import { ContributionsDataTable } from '@/features/projects/components/Contributions/ContributionsDataTable'; +import { ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useRouter } from 'next/navigation'; + +interface ContributionsPageProps { + params: Promise<{ + slug: string; + }>; +} + +export default function ContributionsPage({ params }: ContributionsPageProps) { + const router = useRouter(); + const resolvedParams = use(params); + const slug = resolvedParams.slug; + + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchProject = async () => { + try { + setLoading(true); + const data = await getCrowdfundingProject(slug); + setProject(data); + } catch { + // Error handled by UI state + } finally { + setLoading(false); + } + }; + + fetchProject(); + }, [slug]); + + return ( +
+ {/* Header */} +
+ + +
+

Contributions

+ {project && ( +
+

+ Project:{' '} + + {project.project.title} + +

+ +

+ {project.contributors.length}{' '} + {project.contributors.length === 1 + ? 'Contributor' + : 'Contributors'} +

+
+ )} +
+
+ + {/* Table */} +
+ {loading ? ( +
+
+
+ ) : project ? ( + + ) : ( +
+

Failed to load contributions

+
+ )} +
+
+ ); +} diff --git a/app/(landing)/campaigns/[slug]/milestone/[milestoneId]/page.tsx b/app/(landing)/campaigns/[slug]/milestone/[milestoneId]/page.tsx new file mode 100644 index 00000000..7d96bdc6 --- /dev/null +++ b/app/(landing)/campaigns/[slug]/milestone/[milestoneId]/page.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import MilstoneOverview from '@/components/project-details/project-milestone/milestone-details/MilstoneOverview'; +import MilestoneDetails from '@/components/project-details/project-milestone/milestone-details/MilestoneDetails'; +import MilestoneLinks from '@/components/project-details/project-milestone/milestone-details/MilestoneLinks'; +import { MilestoneStatusCard } from '@/features/projects/components/Milestone/MilestoneStatusCard'; +import { MilestoneEvidence } from '@/features/projects/components/Milestone/MilestoneEvidence'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + getCrowdfundingProject, + getCrowdfundingMilestone, +} from '@/features/projects/api'; +import { Crowdfunding } from '@/features/projects/types'; + +interface MilestonePageProps { + params: Promise<{ + slug: string; // Project slug + milestoneId: string; // Milestone index + }>; +} + +const MilestonePage = async ({ params }: MilestonePageProps) => { + const { slug, milestoneId } = await params; + + // Fetch project data and specific milestone + let project: Crowdfunding | null = null; + let milestone = null; + + try { + // Fetch both project and milestone data + const [crowdfundingProject, milestoneData] = await Promise.all([ + getCrowdfundingProject(slug), + getCrowdfundingMilestone(slug, milestoneId), + ]); + + project = crowdfundingProject; + + // Transform milestone to match component expectations + milestone = milestoneData + ? { + _id: milestoneData.id || milestoneData.name, + title: milestoneData.name, + description: milestoneData.description, + status: milestoneData.status, + dueDate: milestoneData.endDate, + amount: milestoneData.amount, + // Proof and submission data (optional) + ...(milestoneData.submittedAt && { + submittedAt: milestoneData.submittedAt, + }), + ...(milestoneData.approvedAt && { + approvedAt: milestoneData.approvedAt, + }), + ...(milestoneData.rejectedAt && { + rejectedAt: milestoneData.rejectedAt, + }), + ...(milestoneData.evidence && { evidence: milestoneData.evidence }), + // Voting data (optional) + ...(milestoneData.votes && { votes: milestoneData.votes }), + ...(milestoneData.userHasVoted !== undefined && { + userHasVoted: milestoneData.userHasVoted, + }), + ...(milestoneData.userVote && { userVote: milestoneData.userVote }), + } + : null; + } catch { + // Handle error silently - milestone will be null + } + + // If milestone not found, show error state + if (!milestone) { + return ( +
+
+

+ Milestone Not Found +

+

+ The requested milestone could not be found. +

+
+
+ ); + } + + return ( +
+
+ +
+ +
+ + + Details + + + Proof & Status + + + Links + + +
+ + + + + + {milestone.evidence && ( + + )} + + + + +
+
+ ); +}; + +export default MilestonePage; diff --git a/app/(landing)/campaigns/[slug]/page.tsx b/app/(landing)/campaigns/[slug]/page.tsx new file mode 100644 index 00000000..e79be2de --- /dev/null +++ b/app/(landing)/campaigns/[slug]/page.tsx @@ -0,0 +1,398 @@ +'use client'; +import { ProjectLayout } from '@/components/project-details/project-layout'; +import { reportError } from '@/lib/error-reporting'; +import { ProjectLoading } from '@/components/project-details/project-loading'; +import { getCrowdfundingProject } from '@/features/projects/api'; +import type { Crowdfunding } from '@/features/projects/types'; +import { use, useEffect, useState } from 'react'; +import { useSearchParams, notFound } from 'next/navigation'; +import { + getSubmissionDetails, + getHackathon, + type ParticipantSubmission, +} from '@/lib/api/hackathons'; +import type { Hackathon } from '@/lib/api/hackathons'; +import type { + Milestone, + TeamMember, + SocialLink, +} from '@/features/projects/types'; + +interface ProjectPageProps { + params: Promise<{ + slug: string; + }>; +} + +function ProjectContent({ + id, + isSubmission = false, +}: { + id: string; + isSubmission?: boolean; +}) { + const [project, setProject] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchSubmission = async (submissionId: string) => { + try { + const submissionRes = await getSubmissionDetails(submissionId); + + if (submissionRes && submissionRes.data) { + const submission = submissionRes.data; + const subData = submission as any; + + // Try to fetch hackathon details using getHackathon (by ID) + let hackathon: Hackathon | null = null; + try { + if (subData.hackathonId) { + const hackathonRes = await getHackathon(subData.hackathonId); + hackathon = hackathonRes.data; + } + } catch (err) { + reportError(err, { + context: 'project-fetchHackathonDetails', + submissionId: id, + }); + } + + if (hackathon) { + const mappedProject = mapSubmissionToCrowdfunding( + submission, + hackathon + ); + setProject(mappedProject); + return; + } else { + // If hackathon details are missing, we might want to handle it gracefully + // or throw. For now, let's strictly require hackathon details for the map + throw new Error('Hackathon details not found'); + } + } + throw new Error('Submission not found'); + } catch (e) { + reportError(e, { context: 'project-fetchSubmission', id }); + throw e; + } + }; + + const fetchProjectData = async () => { + try { + setLoading(true); + setError(null); + + if (isSubmission) { + await fetchSubmission(id); + return; + } + + try { + const projectData = await getCrowdfundingProject(id); + if (projectData) { + setProject(projectData); + return; + } + } catch (e) { + await fetchSubmission(id); + } + } catch (err) { + reportError(err, { context: 'project-fetch', id }); + setError('Failed to fetch project data'); + } finally { + setLoading(false); + } + }; + + fetchProjectData(); + }, [id, isSubmission]); + + if (loading) { + return ; + } + + if (error || !project) { + notFound(); + } + + if (error || !project) { + notFound(); + } + + return ( +
+
+ +
+
+ ); +} + +// Helper function to map Submission to Crowdfunding type +function mapSubmissionToCrowdfunding( + submission: ParticipantSubmission & { members?: any[] }, + hackathon: Hackathon +): Crowdfunding { + const subData = submission as any; + const hackData = hackathon as any; + + const now = new Date(); + + // Helper to determine status + const getStatus = ( + start?: string, + end?: string + ): 'completed' | 'pending' | 'active' => { + if (!start || !end) return 'pending'; + const startDate = new Date(start); + const endDate = new Date(end); + if (now > endDate) return 'completed'; + if (now >= startDate && now <= endDate) return 'active'; + return 'pending'; + }; + + // Map Hackathon Timeline to Milestones + const milestones: any[] = [ + { + id: 'registration', + name: 'Registration', + title: 'Registration', + description: 'Registration period for the hackathon', + amount: 0, + fundingPercentage: 0, + status: getStatus( + hackData.timeline?.registrationStart, + hackData.timeline?.registrationEnd + ), + reviewStatus: getStatus( + hackData.timeline?.registrationStart, + hackData.timeline?.registrationEnd + ), + startDate: + hackData.timeline?.registrationStart || new Date().toISOString(), + endDate: hackData.timeline?.registrationEnd || new Date().toISOString(), + }, + { + id: 'submission', + name: 'Submission', + title: 'Submission', + description: 'Project submission period', + amount: 0, + fundingPercentage: 0, + status: getStatus( + hackData.timeline?.submissionStart, + hackData.timeline?.submissionEnd + ), + reviewStatus: getStatus( + hackData.timeline?.submissionStart, + hackData.timeline?.submissionEnd + ), + startDate: hackData.timeline?.submissionStart || new Date().toISOString(), + endDate: hackData.timeline?.submissionEnd || new Date().toISOString(), + }, + { + id: 'judging', + name: 'Judging', + title: 'Judging', + description: 'Judging period', + amount: 0, + fundingPercentage: 0, + status: getStatus( + hackData.timeline?.judgingStart, + hackData.timeline?.judgingEnd + ), + reviewStatus: getStatus( + hackData.timeline?.judgingStart, + hackData.timeline?.judgingEnd + ), + startDate: hackData.timeline?.judgingStart || new Date().toISOString(), + endDate: hackData.timeline?.judgingEnd || new Date().toISOString(), + }, + { + id: 'winners', + name: 'Winners Announced', + title: 'Winners Announced', + description: 'Announcement of hackathon winners', + amount: 0, + fundingPercentage: 0, + status: getStatus( + hackData.timeline?.winnersAnnounced, + hackData.timeline?.winnersAnnounced + ), + reviewStatus: getStatus( + hackData.timeline?.winnersAnnounced, + hackData.timeline?.winnersAnnounced + ), + startDate: + hackData.timeline?.winnersAnnounced || new Date().toISOString(), + endDate: hackData.timeline?.winnersAnnounced || new Date().toISOString(), + }, + ]; + + // Map Social Links + const socialLinks: SocialLink[] = (subData.links || []).map((link: any) => ({ + platform: link.type || 'website', + url: link.url, + })); + + // Map Team Members + const teamMembers: TeamMember[] = ( + subData.teamMembers || + subData.members || + [] + ).map((m: any) => ({ + name: m.user?.name || m.name || 'Team Member', + role: m.role || 'Member', + email: '', + image: m.user?.image || m.image, + username: m.user?.username || m.username, + })); + + // Also add the submitter if not in team + if (teamMembers.length === 0 && (subData.participantId || subData.userId)) { + // We might lack detailed user info here, so we use placeholders or available data + teamMembers.push({ + name: subData.participant?.name || subData.user?.name || 'Submitter', + role: 'Leader', + email: subData.participant?.email || subData.user?.email || '', + image: + subData.participant?.image || + subData.user?.image || + subData.logo || + undefined, + username: + subData.participant?.username || subData.user?.username || 'submitter', + }); + } + + const projectId = subData.id || subData._id || ''; + + // Find demo video in links if not provided directly + let demoVideoUrl = subData.videoUrl || ''; + if (!demoVideoUrl && socialLinks.length > 0) { + const vidLink = socialLinks.find( + l => + l.url.includes('youtube.com') || + l.url.includes('youtu.be') || + l.url.includes('vimeo') + ); + if (vidLink) { + demoVideoUrl = vidLink.url; + } + } + + return { + id: projectId, + projectId: projectId, + slug: subData.slug || projectId, + voteGoal: 0, + fundingGoal: 0, + fundingRaised: 0, + fundingCurrency: 'USD', + fundingEndDate: + hackData.timeline?.submissionEnd || new Date().toISOString(), + contributors: [], + team: teamMembers, + contact: { primary: '', backup: '' }, + socialLinks: socialLinks, + milestones: milestones, + stakeholders: null, + trustlessWorkStatus: 'active', + escrowAddress: '', + escrowType: 'none', + escrowDetails: null, + creationTxHash: null, + transactionHash: '', + createdAt: subData.createdAt || new Date().toISOString(), + updatedAt: subData.updatedAt || new Date().toISOString(), + project: { + id: projectId, + title: subData.projectName || 'Untitled Project', + tagline: subData.category || 'Hackathon Project', + description: subData.description || '', + summary: subData.introduction || subData.description || '', + vision: null, + details: null, + category: subData.category || 'General', + status: subData.status || 'pending', + creatorId: subData.participantId || subData.userId || '', + organizationId: subData.organizationId || null, + teamMembers: teamMembers, + banner: null, + logo: subData.logo || '', + thumbnail: null, + githubUrl: + socialLinks.find((l: SocialLink) => + l.platform.toLowerCase().includes('github') + )?.url || '', + gitlabUrl: null, + bitbucketUrl: null, + projectWebsite: + socialLinks.find( + (l: SocialLink) => l.platform === 'website' || l.platform === 'demo' + )?.url || '', + demoVideo: demoVideoUrl, + whitepaperUrl: null, + pitchVideoUrl: null, + socialLinks: socialLinks, + contact: { primary: '', backup: '' }, + whitepaper: null, + pitchDeck: null, + votes: typeof subData.votes === 'number' ? subData.votes : 0, + voting: null, + tags: [], + approvedById: null, + approvedAt: null, + createdAt: subData.createdAt || new Date().toISOString(), + updatedAt: subData.updatedAt || new Date().toISOString(), + creator: { + id: subData.userId || '', + name: subData.participant?.name || subData.user?.name || 'Creator', + email: subData.participant?.email || subData.user?.email || '', + emailVerified: false, + image: subData.participant?.image || subData.user?.image || '', + createdAt: '', + updatedAt: '', + lastLoginMethod: '', + role: '', + banned: false, + banReason: null, + banExpires: null, + username: + subData.participant?.username || subData.user?.username || 'creator', + displayUsername: + subData.participant?.username || subData.user?.username || 'creator', + metadata: null, + twoFactorEnabled: false, + }, + organization: null, + milestones: milestones, + }, + }; +} + +export default function ProjectPage({ params }: ProjectPageProps) { + const [id, setId] = useState(null); + const searchParams = useSearchParams(); + const isSubmission = searchParams.get('type') === 'submission'; + + useEffect(() => { + const getParams = async () => { + const resolvedParams = await params; + setId(resolvedParams.slug); + }; + getParams(); + }, [params]); + + if (!id) { + return ; + } + + return ; +} diff --git a/app/(landing)/campaigns/layout.tsx b/app/(landing)/campaigns/layout.tsx new file mode 100644 index 00000000..3a31c0ce --- /dev/null +++ b/app/(landing)/campaigns/layout.tsx @@ -0,0 +1,12 @@ +import { Metadata } from 'next'; +import { generatePageMetadata } from '@/lib/metadata'; + +export const metadata: Metadata = generatePageMetadata('projects'); + +export default function ProjectsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/app/(landing)/campaigns/page.tsx b/app/(landing)/campaigns/page.tsx new file mode 100644 index 00000000..0c7b9787 --- /dev/null +++ b/app/(landing)/campaigns/page.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import CampaignPageHero from '@/features/projects/components/CampaignPageHero'; +import ProjectsClient from '@/features/projects/components/ProjectsPage'; + +export default function CampaignsPage() { + return ( +
+
+
+ + +
+
+
+ ); +} diff --git a/app/(landing)/projects/page.tsx b/app/(landing)/projects/page.tsx index 2e777d75..21c91485 100644 --- a/app/(landing)/projects/page.tsx +++ b/app/(landing)/projects/page.tsx @@ -1,14 +1,14 @@ import React from 'react'; -import ProjectPageHero from '@/features/projects/components/ProjectPageHero'; -import ProjectsClient from '@/features/projects/components/ProjectsPage'; +import GenericProjectHero from '@/features/projects/components/GenericProjectHero'; +import GenericProjectsClient from '@/features/projects/components/GenericProjectsClient'; export default function ProjectsPage() { return (
- - + +
diff --git a/app/me/projects/create/page.tsx b/app/me/projects/create/page.tsx index c1ff1b6d..57a49c9b 100644 --- a/app/me/projects/create/page.tsx +++ b/app/me/projects/create/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; const page = () => { - redirect('/coming-soon'); + redirect('/projects/create'); }; export default page; diff --git a/app/projects/create/page.tsx b/app/projects/create/page.tsx new file mode 100644 index 00000000..a147d1ea --- /dev/null +++ b/app/projects/create/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React, { Suspense } from 'react'; +import CreationLayout from '@/features/projects/components/CreationFlow/CreationLayout'; + +export default function CreateProjectPage() { + return ( + + Loading... +
+ } + > + + + ); +} diff --git a/components/landing-page/Explore.tsx b/components/landing-page/Explore.tsx index d7659ef2..da3e2e51 100644 --- a/components/landing-page/Explore.tsx +++ b/components/landing-page/Explore.tsx @@ -13,7 +13,7 @@ import { useProjects } from '@/features/projects/hooks/use-project'; import ProjectCard from '@/features/projects/components/ProjectCard'; const ProjectCardSkeleton = () => ( -
+
@@ -25,7 +25,7 @@ const ProjectCardSkeleton = () => (
- +
@@ -155,7 +155,7 @@ export default function Explore() { return (
-

+

Active Opportunities

Explore What's Happening

@@ -270,14 +270,14 @@ export default function Explore() { alt='Glow Effect' width={300} height={200} - className='absolute top-[75px] right-16 -z-[5] max-sm:hidden' + className='absolute top-[75px] right-16 -z-5 max-sm:hidden' /> Glow Effect
); diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx index 32332609..c58d5d2f 100644 --- a/components/landing-page/navbar.tsx +++ b/components/landing-page/navbar.tsx @@ -32,7 +32,6 @@ import { useProtectedAction } from '@/hooks/use-protected-action'; import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; import { WalletTrigger } from '../wallet/WalletTrigger'; import { NotificationBell } from '../notifications/NotificationBell'; -import CreateProjectModal from '@/features/projects/components/CreateProjectModal'; import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; import { useWalletContext } from '../providers/wallet-provider'; @@ -43,6 +42,7 @@ const ACTIONS = { const MENU_ITEMS = [ { href: '/about', label: 'About' }, + { href: '/campaigns', label: 'Campaigns' }, { href: '/projects', label: 'Projects' }, { href: '/hackathons', label: 'Hackathons' }, { href: '/grants', label: 'Grants' }, @@ -196,7 +196,6 @@ function LoadingSkeleton() { } function AuthenticatedActions() { - const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); const [isHovered, setIsHovered] = useState(false); const { @@ -209,7 +208,7 @@ function AuthenticatedActions() { handleWalletConnected, } = useProtectedAction({ actionName: ACTIONS.CREATE_PROJECT, - onSuccess: () => setCreateProjectModalOpen(true), + onSuccess: () => {}, // Handled by Link }); const { onOpenWallet } = useWalletContext(); @@ -251,14 +250,23 @@ function AuthenticatedActions() { align='end' className='bg-background-main-bg/98 w-56 border-white/10 shadow-xl shadow-black/40 backdrop-blur-xl' > - - executeProtectedAction(() => setCreateProjectModalOpen(true)) - } - className='cursor-pointer text-white/80 hover:bg-white/5 hover:text-white focus:bg-white/5 focus:text-white' - > - - Add Project + + + + Add Project + + + + + + Create Campaign +
- setCreateProjectModalOpen(true), + onSuccess: () => {}, // Handled by Link }); const { onOpenWallet } = useWalletContext(); @@ -343,11 +346,6 @@ function UnauthenticatedActions() {
- - { + const section = document.getElementById('explore-campaigns'); + if (!section) return; + + const targetPosition = section.offsetTop - 100; + const startPosition = window.pageYOffset; + const distance = targetPosition - startPosition; + const duration = 1000; + let start: number | null = null; + + const easeInOutQuad = (t: number, b: number, c: number, d: number) => { + t /= d / 2; + if (t < 1) return (c / 2) * t * t + b; + t--; + return (-c / 2) * (t * (t - 2) - 1) + b; + }; + + const animation = (currentTime: number) => { + if (start === null) start = currentTime; + const timeElapsed = currentTime - start; + const run = easeInOutQuad(timeElapsed, startPosition, distance, duration); + window.scrollTo(0, run); + if (timeElapsed < duration) requestAnimationFrame(animation); + }; + + requestAnimationFrame(animation); + }; + + return ( +
+
+
+ {/* Left Text */} +
+

+ + Crowdfunding Campaigns + {' '} + powering the next wave of Stellar innovation +

+ +

+ Support the projects you believe in. Milestone-based funding + ensures accountability. +

+
+ + {/* Buttons */} +
+ + + Explore Campaigns + + + +
+
+
+
+ ); +} diff --git a/features/projects/components/CreationFlow/CreationLayout.tsx b/features/projects/components/CreationFlow/CreationLayout.tsx new file mode 100644 index 00000000..253544de --- /dev/null +++ b/features/projects/components/CreationFlow/CreationLayout.tsx @@ -0,0 +1,248 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import { useProjectCreation } from '@/features/projects/hooks/use-project-creation'; +import { CreationNavigation } from './CreationNavigation'; +import CreationSidebar from './CreationSidebar'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; +import BasicInfo from './Steps/BasicInfo'; +import ProjectDetails from './Steps/ProjectDetails'; +import TeamInfo from './Steps/TeamInfo'; +import SocialLinks from './Steps/SocialLinks'; +import CampaignDetails from './Steps/CampaignDetails'; +import { ArrowRight, ArrowLeft, Save, Sparkles, X, Rocket } from 'lucide-react'; +import ReviewStep from './Steps/ReviewStep'; +import Link from 'next/link'; +import { cn } from '@/lib/utils'; +import { CreationStep } from '@/features/projects/hooks/use-project-creation'; + +export default function CreationLayout() { + const { + currentStep, + steps, + formData, + isCampaign, + setIsCampaign, + updateFormData, + goToStep, + nextStep, + prevStep, + lastSaved, + recentDrafts, + } = useProjectCreation(); + + // Track which steps have been visited + const [visitedSteps, setVisitedSteps] = useState>( + new Set([currentStep]) + ); + // Track which steps are considered "completed" (user moved past them) + const [completedSteps, setCompletedSteps] = useState>( + new Set() + ); + + const currentStepIndex = steps.findIndex(s => s.key === currentStep); + const isLastStep = currentStepIndex === steps.length - 1; + const isFirstStep = currentStepIndex === 0; + + // Keep a stable ref to steps so useEffect can read it without it being a dependency + const stepsRef = useRef(steps); + stepsRef.current = steps; + + // When the step changes, mark previous as completed and new one as visited. + // IMPORTANT: `steps` is intentionally excluded from deps — it's a new array ref + // every render, which would cause an infinite loop via setVisitedSteps. + // We use stepsRef.current for stable reads inside the effect. + const prevStepRef = useRef(currentStep); + useEffect(() => { + const s = stepsRef.current; + if (prevStepRef.current !== currentStep) { + const prevIdx = s.findIndex(st => st.key === prevStepRef.current); + const newIdx = s.findIndex(st => st.key === currentStep); + if (newIdx > prevIdx) { + setCompletedSteps(prev => new Set([...prev, prevStepRef.current])); + } + prevStepRef.current = currentStep; + } + setVisitedSteps(prev => { + if (prev.has(currentStep)) return prev; // avoid new Set allocation when unchanged + return new Set([...prev, currentStep]); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStep]); // `steps` intentionally omitted — use stepsRef + + const handleNavigateToStep = (stepKey: CreationStep) => { + // Mark current step as visited before leaving + setVisitedSteps(prev => new Set([...prev, currentStep])); + goToStep(stepKey); + }; + + const handleNext = () => { + // Mark the current step as completed when moving forward + setCompletedSteps(prev => new Set([...prev, currentStep])); + nextStep(); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Main Content Column — flex column, full height, NO overflow on the column itself */} +
+ {/* ── TOP HEADER ── */} +
+
+ + Step {currentStepIndex + 1} of {steps.length} + +
+
+ {steps.map((s, i) => ( +
+ ))} +
+
+ +
+ {lastSaved && ( +
+ + Saved{' '} + {new Date(lastSaved).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +
+ )} + + + +
+
+ + {/* ── STEP TAB NAVIGATION ── (sticky below header) */} + handleNavigateToStep(val as CreationStep)} + className='flex min-h-0 flex-1 flex-col' + > + + + {/* ── SCROLLABLE CONTENT AREA — flex-1, overflow here ── */} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + {/* ── FIXED FOOTER — always visible at the bottom ── */} +
+ {/* Left group — Back + Save */} +
+ {!isFirstStep && ( + + )} + + +
+ + {/* Right — Next / Publish */} + +
+
+
+
+ ); +} diff --git a/features/projects/components/CreationFlow/CreationNavigation.tsx b/features/projects/components/CreationFlow/CreationNavigation.tsx new file mode 100644 index 00000000..0c534949 --- /dev/null +++ b/features/projects/components/CreationFlow/CreationNavigation.tsx @@ -0,0 +1,105 @@ +'use client'; + +import React from 'react'; +import { TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import { Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { CreationStep } from '@/features/projects/hooks/use-project-creation'; + +interface CreationNavigationProps { + activeTab: CreationStep; + steps: { key: CreationStep; label: string }[]; + navigateToStep: (stepKey: CreationStep) => void; + /** Set of steps that have been visited at least once */ + visitedSteps?: Set; + /** Map of steps that have been validated/completed successfully */ + completedSteps?: Set; +} + +export const CreationNavigation: React.FC = ({ + activeTab, + steps, + navigateToStep, + visitedSteps = new Set(), + completedSteps = new Set(), +}) => { + return ( +
+
+ +
+ + {steps.map((step, index) => { + const isActive = step.key === activeTab; + const isCompleted = completedSteps.has(step.key); + const wasVisited = visitedSteps.has(step.key); + + // A previously visited but NOT completed step should NOT + // get the primary color border — only active or completed steps get it. + const showPrimaryBorder = isActive || isCompleted; + const showMutedBorder = wasVisited && !isActive && !isCompleted; + + return ( + navigateToStep(step.key)} + className={cn( + 'group relative rounded-none border-b-2 bg-transparent px-6 pt-5 pb-4 text-[11px] font-black tracking-[0.15em] uppercase transition-all data-[state=active]:bg-transparent data-[state=active]:shadow-none', + // Active step: primary border + white text + isActive && 'border-b-primary text-white', + // Completed (but not active): subtle white border + dimmed text + isCompleted && + !isActive && + 'border-b-white/30 text-white/60', + // Visited but incomplete: very subtle red/white border indicating something's missing + showMutedBorder && 'border-b-red-500/20 text-white/30', + // Never visited (default) + !isActive && + !wasVisited && + !isCompleted && + 'border-b-transparent text-white/20', + // Hover state for non-active + !isActive && 'hover:text-white/70' + )} + > +
+ {/* Step number / check indicator */} +
+ {isCompleted && !isActive ? ( + + ) : ( + {index + 1} + )} +
+ + {step.label} +
+ + {/* Active underline glow */} + {isActive && ( +
+ )} + + ); + })} + +
+ + +
+
+ ); +}; diff --git a/features/projects/components/CreationFlow/CreationSidebar.tsx b/features/projects/components/CreationFlow/CreationSidebar.tsx new file mode 100644 index 00000000..6f013e97 --- /dev/null +++ b/features/projects/components/CreationFlow/CreationSidebar.tsx @@ -0,0 +1,201 @@ +'use client'; + +import React from 'react'; +import { Plus, FileText, Rocket, Menu, Trash2 } from 'lucide-react'; +import Link from 'next/link'; +import { cn } from '@/lib/utils'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { ProjectDraft } from '@/features/projects/hooks/use-project-creation'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import Image from 'next/image'; + +interface CreationSidebarProps { + recentDrafts: Partial[]; + onDraftSelect?: (draft: Partial) => void; + onDeleteDraft?: (draftId: string) => void; +} + +const SidebarContent = ({ + recentDrafts, + onDeleteDraft, +}: { + recentDrafts: Partial[]; + onDeleteDraft?: (draftId: string) => void; +}) => ( + +); + +export default function CreationSidebar({ + recentDrafts, + onDeleteDraft, +}: CreationSidebarProps) { + return ( + <> + {/* Mobile Trigger */} +
+ + + + + + + + +
+ + {/* Desktop Sidebar */} + + + ); +} diff --git a/features/projects/components/CreationFlow/CreationUI.tsx b/features/projects/components/CreationFlow/CreationUI.tsx new file mode 100644 index 00000000..303b8df4 --- /dev/null +++ b/features/projects/components/CreationFlow/CreationUI.tsx @@ -0,0 +1,603 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { + X, + Loader2, + Image as ImageIcon, + AlertCircle, + Upload, +} from 'lucide-react'; +import Image from 'next/image'; +import { uploadService } from '@/lib/api/upload'; + +// ── Shadcn primitives ──────────────────────────────────────── +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; + +// ═══════════════════════════════════════════════════════════ +// CreationInput — shadcn Input + themed label / helper +// ═══════════════════════════════════════════════════════════ +export const CreationInput = ({ + label, + placeholder, + value, + onChange, + type = 'text', + error, + required = false, + disabled = false, + helperText, + maxLength, + pattern, + name, + id, + className, +}: { + label: string; + placeholder?: string; + value?: string; + onChange?: (e: React.ChangeEvent) => void; + type?: 'text' | 'email' | 'url' | 'number' | 'tel' | 'date' | 'password'; + error?: string; + required?: boolean; + disabled?: boolean; + helperText?: string; + maxLength?: number; + pattern?: string; + name?: string; + id?: string; + className?: string; +}) => { + const inputId = id || name || label.toLowerCase().replace(/\s+/g, '-'); + const charCount = value?.length ?? 0; + + return ( +
+ {/* Label row */} + {label && ( +
+ + {maxLength !== undefined && value !== undefined && ( + = maxLength + ? 'text-red-400' + : charCount >= maxLength * 0.9 + ? 'text-amber-400' + : 'text-white/30' + )} + > + {charCount}/{maxLength} + + )} +
+ )} + + {/* Input — shadcn base, override colours for dark theme */} + + + {/* Helper / Error */} + {(error || helperText) && ( +
+ {error && ( + + )} +

+ {error || helperText} +

+
+ )} +
+ ); +}; + +// ═══════════════════════════════════════════════════════════ +// CreationTextarea — shadcn Textarea + themed label / helper +// ═══════════════════════════════════════════════════════════ +export const CreationTextarea = ({ + label, + placeholder, + value, + onChange, + error, + required = false, + disabled = false, + helperText, + maxLength, + rows = 5, + name, + id, + className, +}: { + label: string; + placeholder?: string; + value?: string; + onChange?: (e: React.ChangeEvent) => void; + error?: string; + required?: boolean; + disabled?: boolean; + helperText?: string; + maxLength?: number; + rows?: number; + name?: string; + id?: string; + className?: string; +}) => { + const inputId = id || name || label.toLowerCase().replace(/\s+/g, '-'); + const charCount = value?.length ?? 0; + + return ( +
+ {label && ( +
+ + {maxLength !== undefined && value !== undefined && ( + = maxLength + ? 'text-red-400' + : charCount >= maxLength * 0.9 + ? 'text-amber-400' + : 'text-white/30' + )} + > + {charCount}/{maxLength} + + )} +
+ )} + +