diff --git a/.env.example b/.env.example index 50e04a86..44524149 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,6 @@ NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="your_wallet_connect_project_id" NEXT_PUBLIC_SENTRY_DSN="" SENTRY_DSN="" SENTRY_ORG="" -SENTRY_PROJECT="boundless-next" -SENTRY_AUTH_TOKEN="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" +SENTRY_PROJECT="" +SENTRY_AUTH_TOKEN="" NODE_ENV="dev" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 925a8879..6ce85daf 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin 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/api/sentry-example-api/route.ts b/app/api/sentry-example-api/route.ts new file mode 100644 index 00000000..65150805 --- /dev/null +++ b/app/api/sentry-example-api/route.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/nextjs'; +export const dynamic = 'force-dynamic'; + +class SentryExampleAPIError extends Error { + constructor(message: string | undefined) { + super(message); + this.name = 'SentryExampleAPIError'; + } +} + +// A faulty API route to test Sentry's error monitoring +export function GET() { + Sentry.logger.info('Sentry example API called'); + throw new SentryExampleAPIError( + 'This error is raised on the backend called by the example page.' + ); +} 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/app/sentry-example-page/page.tsx b/app/sentry-example-page/page.tsx new file mode 100644 index 00000000..4b5ed059 --- /dev/null +++ b/app/sentry-example-page/page.tsx @@ -0,0 +1,239 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import Head from 'next/head'; +import { useEffect, useState } from 'react'; + +class SentryExampleFrontendError extends Error { + constructor(message: string | undefined) { + super(message); + this.name = 'SentryExampleFrontendError'; + } +} + +export default function Page() { + const [hasSentError, setHasSentError] = useState(false); + const [isConnected, setIsConnected] = useState(true); + + useEffect(() => { + Sentry.logger.info('Sentry example page loaded'); + async function checkConnectivity() { + const result = await Sentry.diagnoseSdkConnectivity(); + setIsConnected(result !== 'sentry-unreachable'); + } + checkConnectivity(); + }, []); + + return ( +
+ + sentry-example-page + + + +
+
+ + + +

sentry-example-page

+ +

+ Click the button below, and view the sample error on the Sentry{' '} + + Issues Page + + . For more details about setting up Sentry,{' '} + + read our docs + + . +

+ + + + {hasSentError ? ( +

Error sent to Sentry.

+ ) : !isConnected ? ( +
+

+ It looks like network requests to Sentry are being blocked, which + will prevent errors from being captured. Try disabling your + ad-blocker to complete the test. +

+
+ ) : ( +
+ )} + +
+
+ + +
+ ); +} 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 res = await api.post>( + '/projects/drafts', + data + ); + return res.data.data; +}; + +/** + * PATCH /api/projects/{id}/draft + * Autosave / update an existing project draft (stepped form). + * Accepts a partial payload — only changed fields need to be sent. + */ +export const updateProjectDraft = async ( + id: string, + data: Partial +): Promise => { + const res = await api.patch>( + `/projects/${id}/draft`, + data + ); + return res.data.data; +}; + +/** + * GET /api/projects + * List all public projects (PRD products directory). + */ +export const listPublicProjects = async (): Promise => { + const res = await api.get>('/projects'); + return res.data.data?.projects ?? []; +}; + +/** + * GET /api/projects/search?search= + * Full-text search over public projects. + */ +export const searchProjects = async (query: string): Promise => { + const params = new URLSearchParams({ search: query }); + const res = await api.get>( + `/projects/search?${params.toString()}` + ); + return res.data.data?.projects ?? []; +}; + +/** + * GET /api/projects/featured + * List featured / curated projects. + */ +export const listFeaturedProjects = async (): Promise => { + const res = + await api.get>('/projects/featured'); + return res.data.data?.projects ?? []; +}; + +/** + * POST /api/projects/{id}/publish + * Publish/submit a project draft for review (Review & Submit step). + * Returns the updated project entity (HTTP 201). + */ +export const publishProject = async ( + id: string, + payload: PublishProjectRequest +): Promise => { + const res = await api.post>( + `/projects/${id}/publish`, + payload + ); + return res.data.data; +}; + +/** + * GET /api/projects/me + * List the authenticated user's own projects (with optional pagination). + */ +export const getMyProjects = async ( + params?: GetMyProjectsParams +): Promise> => { + const searchParams = new URLSearchParams(); + + if (params?.limit !== undefined) { + searchParams.append('limit', params.limit.toString()); + } + if (params?.offset !== undefined) { + searchParams.append('offset', params.offset.toString()); + } + if (params?.status) { + searchParams.append('status', params.status); + } + + const qs = searchParams.toString(); + const url = qs ? `/projects/me?${qs}` : '/projects/me'; + + const res = await api.get>(url); + return res.data; +}; + +/** + * GET /api/projects/{slug} + * Get a single public project by slug. + */ +export const getPublicProjectBySlug = async ( + slug: string +): Promise => { + const res = await api.get>(`/projects/${slug}`); + return res.data.data; +}; + +/** + * GET /api/projects/me/{id} + * Get the authenticated user's own project by ID. + */ +export const getMyProjectById = async (id: string): Promise => { + const res = await api.get>(`/projects/me/${id}`); + return res.data.data; +}; diff --git a/features/projects/components/CampaignPageHero.tsx b/features/projects/components/CampaignPageHero.tsx new file mode 100644 index 00000000..2bd3debe --- /dev/null +++ b/features/projects/components/CampaignPageHero.tsx @@ -0,0 +1,71 @@ +'use client'; +import React from 'react'; +import { ArrowDown } from 'lucide-react'; +import { BoundlessButton } from '@/components/buttons'; + +export default function CampaignPageHero() { + const scrollToCampaigns = () => { + 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..b5d257d0 --- /dev/null +++ b/features/projects/components/CreationFlow/CreationLayout.tsx @@ -0,0 +1,335 @@ +'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, + Loader2, + AlertTriangle, + CheckCircle2, +} 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, + draftId, + isPersisting, + persistError, + isPublishing, + publishError, + handlePublish, + isLoadingDraft, + onDeleteDraft, + } = useProjectCreation(); + + // Combined error (save or publish) + const activeError = persistError || publishError; + + // 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 */} + + + {/* Loading Overlay */} + {isLoadingDraft && ( +
+
+ +

+ Loading Draft… +

+
+
+ )} + + {/* 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) => ( +
+ ))} +
+
+ +
+ {/* Live save indicator */} + {isPersisting && ( +
+ + Saving… +
+ )} + {!isPersisting && lastSaved && ( +
+ + Saved{' '} + {new Date(lastSaved).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +
+ )} + {!isPersisting && persistError && !lastSaved && ( +
+ + Not saved +
+ )} + + + +
+
+ + {/* ── STEP TAB NAVIGATION ── (sticky below header) */} + handleNavigateToStep(val as CreationStep)} + className='flex min-h-0 flex-1 flex-col' + > + + + {/* ── ERROR BANNER — shown below nav when save/publish fails ── */} + {activeError && ( +
+ +
+ {activeError.split('\n').map((err, i) => ( +

+ • {err} +

+ ))} +
+
+ )} + + {/* ── SCROLLABLE CONTENT AREA — flex-1, overflow here ── */} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + {/* ── FIXED FOOTER — always visible at the bottom ── */} +
+ {/* Left group — Back + Save Draft */} +
+ {!isFirstStep && ( + + )} + + {/* Save Draft — visible on md+, triggers an immediate autosave */} + +
+ + {/* Right — Continue / Publish Project */} + +
+
+
+
+ ); +} 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..3c9b53e7 --- /dev/null +++ b/features/projects/components/CreationFlow/CreationSidebar.tsx @@ -0,0 +1,206 @@ +'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} + + )} +
+ )} + +