diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..cab285df2 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "boundless-v1", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx b/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx new file mode 100644 index 000000000..c022905fb --- /dev/null +++ b/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx @@ -0,0 +1,383 @@ +'use client'; + +import { use } from 'react'; +import Link from 'next/link'; +import { + ArrowLeft, + CalendarDays, + CheckCircle2, + Clock, + ExternalLink, + FileText, + AlertTriangle, + RefreshCw, +} from 'lucide-react'; + +import { useCampaign, useMilestone } from '@/features/crowdfunding'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { milestoneState, TONE_PILL } from '@/lib/crowdfunding/status'; + +interface PageProps { + params: Promise<{ slug: string; id: string }>; +} + +interface MilestoneDetail { + id: string; + title?: string | null; + name?: string | null; + description?: string | null; + deliverable?: string | null; + successCriteria?: string | null; + fundingPercentage?: number | null; + amount?: number | null; + expectedDeliveryDate?: string | null; + endDate?: string | null; + startDate?: string | null; + orderIndex?: number | null; + reviewStatus?: string | null; + submittedAt?: string | null; + proofOfWorkFiles?: string[]; + proofOfWorkLinks?: string[]; + submissionNotes?: string | null; + reviewedAt?: string | null; + rejectionReason?: string | null; + rejectionFeedback?: string | null; + resubmissionDeadline?: string | null; + completedAt?: string | null; + claimedAt?: string | null; + isOverdue?: boolean; + daysRemaining?: number | null; +} + +function formatDate(dateStr?: string | null): string | null { + if (!dateStr) return null; + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function MetaRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +export default function PublicMilestoneDetailPage({ params }: PageProps) { + const { slug, id } = use(params); + const { data: campaign, isLoading: campaignLoading } = useCampaign(slug); + const { data: milestoneRaw, isLoading: milestoneLoading } = useMilestone( + campaign?.id ?? null, + id + ); + + const isLoading = campaignLoading || (!!campaign?.id && milestoneLoading); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (!campaign || !milestoneRaw) { + return ( +
+ Milestone not found. +
+ ); + } + + const m = milestoneRaw as MilestoneDetail; + const milestones = campaign.milestones ?? []; + const idx = milestones.findIndex(ms => ms.id === id); + const orderNumber = idx >= 0 ? idx + 1 : null; + + const st = milestoneState(m.reviewStatus, m.claimedAt); + const dueDate = formatDate(m.expectedDeliveryDate ?? m.endDate); + const submittedAt = formatDate(m.submittedAt); + const reviewedAt = formatDate(m.reviewedAt); + + const totalAmount = milestones.reduce((s, ms) => s + (ms.amount ?? 0), 0); + const pct = + totalAmount > 0 && m.amount != null + ? Math.round((m.amount / totalAmount) * 100) + : null; + + const hasEvidence = + m.submittedAt || + (m.proofOfWorkLinks?.length ?? 0) > 0 || + (m.proofOfWorkFiles?.length ?? 0) > 0 || + m.submissionNotes; + + const hasReviewOutcome = + m.reviewedAt || m.rejectionFeedback || m.rejectionReason || m.claimedAt; + + const isRejected = + m.reviewStatus === 'REJECTED' || m.reviewStatus === 'RESUBMISSION_REQUIRED'; + + return ( +
+ {/* Nav bar */} +
+
+ + + All milestones + +
+
+ +
+ {/* Header */} +
+
+
+ {campaign.project.title} + {orderNumber && ( + <> + / + Milestone {orderNumber} + + )} +
+

+ {m.title ?? m.name ?? 'Untitled milestone'} +

+
+ + {st.label} + +
+ + {/* Key facts */} +
+
+ {m.amount != null && ( + + ${m.amount.toLocaleString()} USDC + {pct != null && ( + + {pct}% of total + + )} + + } + /> + )} + {dueDate && ( + + + {dueDate} + {m.isOverdue && ( + Overdue + )} + + } + /> + )} + {submittedAt && ( + + + {submittedAt} + + } + /> + )} + {reviewedAt && ( + + + {reviewedAt} + + } + /> + )} +
+
+ + {/* Description */} + {m.description && ( +
+

+ {m.description} +

+
+ )} + + {/* Deliverable */} + {m.deliverable && ( +
+

+ {m.deliverable} +

+
+ )} + + {/* Success criteria */} + {m.successCriteria && ( +
+

+ {m.successCriteria} +

+
+ )} + + {/* Evidence submitted by the builder */} + {hasEvidence && ( +
+ {m.submissionNotes && ( +
+

Notes

+

+ {m.submissionNotes} +

+
+ )} + + {(m.proofOfWorkLinks?.length ?? 0) > 0 && ( +
+

Links

+
    + {m.proofOfWorkLinks!.map((link, i) => ( +
  • + + + {link} + +
  • + ))} +
+
+ )} + + {(m.proofOfWorkFiles?.length ?? 0) > 0 && ( +
+

Files

+ +
+ )} +
+ )} + + {/* Review outcome */} + {hasReviewOutcome && ( +
+ {m.claimedAt && ( +
+ +
+

+ Paid out +

+ {m.amount != null && ( +

+ ${m.amount.toLocaleString()} USDC +

+ )} + {formatDate(m.claimedAt) && ( +

+ {formatDate(m.claimedAt)} +

+ )} +
+
+ )} + + {isRejected && (m.rejectionReason || m.rejectionFeedback) && ( +
+ +
+

+ {m.reviewStatus === 'RESUBMISSION_REQUIRED' + ? 'Resubmission required' + : 'Not accepted'} +

+ {m.rejectionReason && ( +

+ {m.rejectionReason} +

+ )} + {m.rejectionFeedback && ( +

+ {m.rejectionFeedback} +

+ )} + {m.resubmissionDeadline && ( +
+ + Resubmit by {formatDate(m.resubmissionDeadline)} +
+ )} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/app/(landing)/crowdfunding/[slug]/milestones/page.tsx b/app/(landing)/crowdfunding/[slug]/milestones/page.tsx new file mode 100644 index 000000000..815b8e66c --- /dev/null +++ b/app/(landing)/crowdfunding/[slug]/milestones/page.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { use } from 'react'; +import Link from 'next/link'; +import { ArrowLeft, CalendarDays, ChevronRight } from 'lucide-react'; + +import { useCampaign } from '@/features/crowdfunding'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { + campaignStatus, + milestoneState, + TONE_PILL, +} from '@/lib/crowdfunding/status'; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +function formatDate(dateStr?: string): string | null { + if (!dateStr) return null; + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +export default function PublicMilestonesPage({ params }: PageProps) { + const { slug } = use(params); + const { data: campaign, isLoading } = useCampaign(slug); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (!campaign) { + return ( +
+ Campaign not found. +
+ ); + } + + const status = campaignStatus(campaign.v2Status); + const milestones = campaign.milestones ?? []; + const totalAmount = milestones.reduce((s, m) => s + (m.amount ?? 0), 0); + + return ( +
+
+
+ + + Back to campaign + +
+
+ +
+ {/* Campaign header */} +
+ {campaign.project.logo && ( + // eslint-disable-next-line @next/next/no-img-element + {campaign.project.title} + )} +
+

+ {campaign.project.title} +

+
+ + {status.label} + +
+ +
+

+ Milestones{' '} + + ({milestones.length}) + +

+

+ Funds are released one stage at a time as each milestone is + delivered and approved. +

+
+ + {milestones.length === 0 ? ( +

No milestones listed.

+ ) : ( +
+ {milestones.map((m, idx) => { + const st = milestoneState(m.reviewStatus); + const planned = formatDate(m.endDate); + const pct = + totalAmount > 0 + ? Math.round(((m.amount ?? 0) / totalAmount) * 100) + : 0; + + return m.id ? ( + + {/* Number chip */} +
+ {idx + 1} +
+ +
+

+ {m.title || m.name} +

+ {m.description && ( +

+ {m.description} +

+ )} +
+ {m.amount != null && ( + ${m.amount.toLocaleString()} USDC + )} + {pct > 0 && {pct}% of total} + {planned && ( + + + {planned} + + )} +
+
+ +
+ + {st.label} + + +
+ + ) : ( +
+
+ {idx + 1} +
+
+

+ {m.title || m.name} +

+
+ + {st.label} + +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/app/(landing)/crowdfunding/[slug]/page.tsx b/app/(landing)/crowdfunding/[slug]/page.tsx new file mode 100644 index 000000000..17aacf54f --- /dev/null +++ b/app/(landing)/crowdfunding/[slug]/page.tsx @@ -0,0 +1,625 @@ +'use client'; + +import { useState, use } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { + ArrowLeft, + Users, + Github, + Globe, + Video, + ExternalLink, + ShieldCheck, + CalendarDays, +} from 'lucide-react'; + +import { useCampaign } from '@/features/crowdfunding'; +import type { CrowdfundingContributor } from '@/features/crowdfunding'; +import { + ContributeSheet, + MIN_CONTRIBUTION, +} from '@/components/crowdfunding/ContributeSheet'; +import { VotePanel } from '@/components/crowdfunding/VotePanel'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { + campaignStatus, + milestoneState, + TONE_PILL, +} from '@/lib/crowdfunding/status'; +import { getTransactionExplorerUrl } from '@/lib/wallet-utils'; +import { useAuthStatus } from '@/hooks/use-auth'; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +function daysLeft(dateStr?: string): number | null { + if (!dateStr) return null; + const diff = new Date(dateStr).getTime() - Date.now(); + return Math.max(0, Math.ceil(diff / 86_400_000)); +} + +function formatDate(dateStr?: string): string | null { + if (!dateStr) return null; + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +function pct(raised: number, goal: number): number { + if (!goal) return 0; + return Math.min(100, Math.round((raised / goal) * 100)); +} + +function SupporterRow({ c }: { c: CrowdfundingContributor }) { + const isAnon = !c.username && !c.name; + const dateLabel = new Date(c.date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + return ( +
+
+ {c.image ? ( + {c.name + ) : ( +
+ {isAnon + ? '?' + : (c.name || c.username || 'S').charAt(0).toUpperCase()} +
+ )} +
+
+

+ {isAnon ? 'Anonymous supporter' : c.name || c.username} +

+ {c.message && ( +

{c.message}

+ )} +
+
+

+ ${c.amount.toLocaleString()} +

+ {c.transactionHash ? ( + + {dateLabel} + + + ) : ( +

{dateLabel}

+ )} +
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export default function PublicCampaignPage({ params }: PageProps) { + const { slug } = use(params); + const { data: campaign, isLoading } = useCampaign(slug); + const [sheetOpen, setSheetOpen] = useState(false); + const { user } = useAuthStatus(); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (!campaign) { + return ( +
+ Campaign not found. +
+ ); + } + + const project = campaign.project; + const raised = campaign.fundingRaised ?? 0; + const goal = campaign.fundingGoal ?? 0; + const progress = pct(raised, goal); + const days = daysLeft(campaign.fundingEndDate); + const closeDate = formatDate(campaign.fundingEndDate); + const supporters = campaign.contributors ?? []; + + const isFunding = campaign.v2Status === 'FUNDING'; + const isVoting = campaign.v2Status === 'VOTING'; + const isComplete = campaign.v2Status === 'COMPLETED'; + const showFundingStats = isFunding || isComplete; + // Funded once less than the contract minimum remains (the last sliver can't + // be filled per the on-chain floor), so we stop offering "Back this project". + const isFullyFunded = + isFunding && goal > 0 && goal - raised < MIN_CONTRIBUTION; + + const completedMilestones = campaign.milestones.filter(m => + Boolean(m.claimedAt) + ).length; + + const baseStatus = campaignStatus(campaign.v2Status); + const status = isFullyFunded + ? { + label: 'Fully Funded', + tone: 'success' as const, + description: baseStatus.description, + } + : baseStatus; + + const isOwnerOrTeam = + !!user?.id && + (user.id === project.creatorId || + campaign.team.some(m => m.id === user.id)); + // Statuses the public is allowed to observe. All others are pre-launch or + // terminal-negative and should not expose internal status copy to strangers. + const isPublicStatus = + campaign.v2Status === 'VOTING' || + campaign.v2Status === 'FUNDING' || + campaign.v2Status === 'PAUSED' || + campaign.v2Status === 'COMPLETED'; + + return ( +
+ {/* Back link */} +
+
+ + + All campaigns + +
+
+ + {/* Hero */} + {project.banner ? ( +
+ {project.title} +
+
+ ) : ( +
+ )} + +
+ {/* Title row */} +
+ {project.logo && ( +
+ {project.title} +
+ )} +
+
+

+ {project.title} +

+ + {status.label} + +
+ {project.tagline && ( +

{project.tagline}

+ )} +
+
+ +
+ {/* Main content — single scroll */} +
+ {/* Story */} +
+
+ {project.vision || project.description || ( + + No description provided. + + )} +
+ {(project.githubUrl || + project.projectWebsite || + project.demoVideo) && ( +
+ {project.githubUrl && ( + + + Code + + + )} + {project.projectWebsite && ( + + + Website + + + )} + {project.demoVideo && ( + + + )} +
+ )} +
+ + {/* Milestones */} +
0 + ? `Milestones (${completedMilestones} / ${campaign.milestones.length} delivered)` + : 'Milestones' + } + > +

+ The plan is delivered in stages. Funds are released to the team + one stage at a time, as each is delivered and approved by a + reviewer. +

+ {campaign.milestones.length === 0 ? ( +

No milestones listed.

+ ) : ( +
+ {campaign.milestones.map((m, idx) => { + const st = milestoneState(m.reviewStatus, m.claimedAt); + const planned = formatDate(m.endDate); + const inner = ( + <> +
+
+

+ {idx + 1}. {m.title || m.name} +

+ {m.description && ( +

+ {m.description} +

+ )} +
+
+ {showFundingStats && m.amount != null && ( + + ${m.amount.toLocaleString()} + + )} + + {st.label} + +
+
+ {planned && ( +

+ + Planned for {planned} +

+ )} + + ); + return m.id ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ); + })} +
+ )} +
+ + {/* Team */} + {campaign.team.length > 0 && ( +
+
+ {campaign.team.map((m, idx) => ( +
+
+ {m.image ? ( + {m.name} + ) : ( +
+ {m.name.charAt(0).toUpperCase()} +
+ )} +
+
+

+ {m.name} +

+

{m.role}

+
+
+ ))} +
+
+ )} + + {/* Supporters */} +
+ {supporters.length === 0 ? ( +
+ +

+ No supporters yet. + {isFunding ? ' Be the first to back this.' : ''} +

+
+ ) : ( +
+ {supporters.map((c, i) => ( + + ))} +
+ )} +
+
+ + {/* Sticky sidebar — funding + action */} +
+
+
+ {showFundingStats ? ( + <> +
+
+
+
+
+ {progress}% funded + {isFunding && days !== null && ( + + {days} {days === 1 ? 'day' : 'days'} left + + )} +
+
+
+

+ ${raised.toLocaleString()} +

+

+ raised of ${goal.toLocaleString()} goal +

+
+

+ {supporters.length}{' '} + {supporters.length === 1 ? 'supporter' : 'supporters'} + {isFunding && closeDate ? ` · closes ${closeDate}` : ''} +

+ + ) : ( +
+
+

+ ${goal.toLocaleString()}{' '} + + {campaign.fundingCurrency ?? 'USDC'} + +

+

funding goal

+
+ {campaign.milestones.length > 0 && ( +

+ {campaign.milestones.length}{' '} + {campaign.milestones.length === 1 + ? 'milestone' + : 'milestones'}{' '} + planned +

+ )} +
+ )} + + {/* Primary action by state */} + {isOwnerOrTeam ? ( + // Owner/team: show their private status info; no voting or funding actions. +
+

{status.label}

+

+ {status.description} +

+
+ ) : isFunding && !isFullyFunded ? ( + <> + +

+ + Your support is held safely and released to the team as + each milestone is delivered and approved. +

+ + ) : isFullyFunded ? ( +
+

+ Fully funded +

+

+ This campaign reached its goal. Thanks to all{' '} + {supporters.length}{' '} + {supporters.length === 1 ? 'supporter' : 'supporters'}. +

+
+ ) : isVoting ? ( + + ) : isPublicStatus ? ( +
+

{status.label}

+

+ {status.description} +

+
+ ) : ( + // Pre-launch or terminal state accessed via direct URL; not in public listing. +
+

+ Not yet open +

+

+ This campaign is not yet available to the public. +

+
+ )} +
+ + {/* Creator */} + {project.creator && ( +
+

+ Creator +

+
+
+ {project.creator.image ? ( + {project.creator.name} + ) : ( +
+ {(project.creator.name || 'C') + .charAt(0) + .toUpperCase()} +
+ )} +
+
+

+ {project.creator.name} +

+ {project.creator.username && ( +

+ @{project.creator.username} +

+ )} +
+
+
+ )} +
+
+
+
+ + {!isOwnerOrTeam && ( + + )} +
+ ); +} diff --git a/app/(landing)/crowdfunding/new/page.tsx b/app/(landing)/crowdfunding/new/page.tsx new file mode 100644 index 000000000..639161779 --- /dev/null +++ b/app/(landing)/crowdfunding/new/page.tsx @@ -0,0 +1,22 @@ +import { Metadata } from 'next'; +import { AuthGuard } from '@/components/auth'; +import { Suspense } from 'react'; +import NewCampaignWizard from '@/components/crowdfunding/new/NewCampaignWizard'; + +export const metadata: Metadata = { + title: 'New Campaign | Boundless', + description: 'Create a new crowdfunding campaign on Boundless', +}; + +export default function NewCampaignPage() { + return ( + Authenticating...
} + > + Loading...
}> + + + + ); +} diff --git a/app/(landing)/crowdfunding/page.tsx b/app/(landing)/crowdfunding/page.tsx new file mode 100644 index 000000000..b46b0855b --- /dev/null +++ b/app/(landing)/crowdfunding/page.tsx @@ -0,0 +1,15 @@ +import CrowdfundingExplore from '@/features/crowdfunding/components/CrowdfundingExplore'; +import CrowdfundingPageHero from '@/features/crowdfunding/components/CrowdfundingPageHero'; + +export default function CrowdfundingPage() { + return ( +
+
+
+ + +
+
+
+ ); +} diff --git a/app/(landing)/projects/[slug]/page.tsx b/app/(landing)/projects/[slug]/page.tsx index 5f3af021e..393a8e556 100644 --- a/app/(landing)/projects/[slug]/page.tsx +++ b/app/(landing)/projects/[slug]/page.tsx @@ -5,7 +5,7 @@ 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 { useSearchParams, notFound, useRouter } from 'next/navigation'; import { getSubmissionDetails, getHackathon, @@ -34,6 +34,15 @@ function ProjectContent({ const [project, setProject] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const router = useRouter(); + + // Crowdfunding campaigns have their own dedicated, jargon-free page. Never + // show them inside the generic project layout — send them there instead. + const crowdfundingSlug = + !isSubmission && project?.v2Status ? project.slug : null; + useEffect(() => { + if (crowdfundingSlug) router.replace(`/crowdfunding/${crowdfundingSlug}`); + }, [crowdfundingSlug, router]); useEffect(() => { const fetchSubmission = async (submissionId: string) => { @@ -108,7 +117,7 @@ function ProjectContent({ fetchProjectData(); }, [id, isSubmission]); - if (loading) { + if (loading || crowdfundingSlug) { return ; } @@ -116,10 +125,6 @@ function ProjectContent({ notFound(); } - if (error || !project) { - notFound(); - } - return (
diff --git a/app/me/crowdfunding/[slug]/components/CampaignBanner.tsx b/app/me/crowdfunding/[slug]/components/CampaignBanner.tsx deleted file mode 100644 index 09e9215b5..000000000 --- a/app/me/crowdfunding/[slug]/components/CampaignBanner.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Card, CardContent } from '@/components/ui/card'; -import Image from 'next/image'; - -interface CampaignBannerProps { - project: { - banner: string | null; - title: string; - }; -} - -export function CampaignBanner({ project }: CampaignBannerProps) { - if (!project.banner) return null; - - return ( - - - {project.title} - - - ); -} diff --git a/app/me/crowdfunding/[slug]/components/CampaignTabs.tsx b/app/me/crowdfunding/[slug]/components/CampaignTabs.tsx index 0e2f4a6a1..7ebda1d2b 100644 --- a/app/me/crowdfunding/[slug]/components/CampaignTabs.tsx +++ b/app/me/crowdfunding/[slug]/components/CampaignTabs.tsx @@ -1,74 +1,36 @@ -import { Target, DollarSign, Users } from 'lucide-react'; +import { Users, MessageSquare } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { CampaignTeamTab } from '@/components/crowdfunding/campaign-team-tab'; -import { CampaignMilestonesTab } from '@/components/crowdfunding/campaign-milestones-tab'; import { CampaignCommentsTab } from '@/components/crowdfunding/campaign-comments-tab'; -import { CampaignFundingTab } from '@/components/crowdfunding/campaign-funding-tab'; import { Crowdfunding } from '@/features/projects/types'; interface CampaignTabsProps { campaign: Crowdfunding; } +// Milestones and Funding/Contributions are now top-level tabs in the campaign +// layout, so this only carries Team + Comments. export function CampaignTabs({ campaign }: CampaignTabsProps) { return ( - + Team - - - Milestones - - - - + Comments - - - Funding - - - - - - - - - ); } diff --git a/app/me/crowdfunding/[slug]/components/FundingProgress.tsx b/app/me/crowdfunding/[slug]/components/FundingProgress.tsx deleted file mode 100644 index 188ced83b..000000000 --- a/app/me/crowdfunding/[slug]/components/FundingProgress.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { format } from 'date-fns'; -import { Target } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Progress } from '@/components/ui/progress'; -import { Separator } from '@/components/ui/separator'; -import { Crowdfunding } from '@/features/projects/types'; - -interface FundingProgressProps { - campaign: Crowdfunding; -} - -export function FundingProgress({ campaign }: FundingProgressProps) { - const fundingProgress = - campaign.fundingGoal > 0 - ? (campaign.fundingRaised / campaign.fundingGoal) * 100 - : 0; - const daysLeft = Math.ceil( - (new Date(campaign.fundingEndDate).getTime() - new Date().getTime()) / - (1000 * 60 * 60 * 24) - ); - - return ( - - - - - Funding Progress - - - -
-
- ${campaign.fundingRaised.toLocaleString()} -
-
- of ${campaign.fundingGoal.toLocaleString()} goal -
-
- - - -
-
-
- {fundingProgress.toFixed(1)}% -
-
Funded
-
-
-
- {daysLeft > 0 ? daysLeft : 0} -
-
- {daysLeft > 0 ? 'Days Left' : 'Ended'} -
-
-
- - - -
-
- Currency - {campaign.fundingCurrency} -
-
- End Date - - {format(new Date(campaign.fundingEndDate), 'MMM dd, yyyy')} - -
-
-
-
- ); -} diff --git a/app/me/crowdfunding/[slug]/components/ProjectDetails.tsx b/app/me/crowdfunding/[slug]/components/ProjectDetails.tsx index af759ba25..62d671171 100644 --- a/app/me/crowdfunding/[slug]/components/ProjectDetails.tsx +++ b/app/me/crowdfunding/[slug]/components/ProjectDetails.tsx @@ -1,87 +1,40 @@ -import { format } from 'date-fns'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useMarkdown } from '@/hooks/use-markdown'; import { Crowdfunding } from '@/features/projects/types'; -import Image from 'next/image'; interface ProjectDetailsProps { campaign: Crowdfunding; project: Crowdfunding['project']; } -export function ProjectDetails({ campaign, project }: ProjectDetailsProps) { - const { styledContent } = useMarkdown(project.description || '', { +// Identity (logo, title, status) lives in the shared campaign layout header, +// so this is just the campaign story — no duplicated title/status/date. +export function ProjectDetails({ project }: ProjectDetailsProps) { + // `vision` is the canonical "Project story" the wizard writes; description / + // details are legacy fields. Match the public page's precedence so the same + // campaign shows the same story on both surfaces. + const body = project.vision || project.description || project.details || ''; + const { styledContent } = useMarkdown(body, { breaks: true, gfm: true, pedantic: true, loadingDelay: 0, }); - const getStatusColor = (status: string) => { - switch (status.toLowerCase()) { - case 'active': - case 'funding': - return 'bg-green-500/20 text-green-400 border-green-500/30'; - case 'completed': - return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; - case 'draft': - return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'; - default: - return 'bg-gray-500/20 text-gray-400 border-gray-500/30'; - } - }; - return ( -
-
- {project.logo && ( - {project.title} - )} -
- {project.title} - - Created {format(new Date(campaign.createdAt), 'MMM dd, yyyy')} - -
-
- - {project.status} - -
+ About this campaign
- -
-

Description

+ + {body ? (
{styledContent}
-
- - {project.details && ( -
-

Details

-

{project.details}

-
- )} - - {project.vision && ( -
-

Vision

-

{project.vision}

-
+ ) : ( +

+ No description added yet. +

)}
diff --git a/app/me/crowdfunding/[slug]/components/index.ts b/app/me/crowdfunding/[slug]/components/index.ts index da09ad809..24f09d350 100644 --- a/app/me/crowdfunding/[slug]/components/index.ts +++ b/app/me/crowdfunding/[slug]/components/index.ts @@ -1,6 +1,4 @@ -export { CampaignBanner } from './CampaignBanner'; export { ProjectDetails } from './ProjectDetails'; export { CampaignTabs } from './CampaignTabs'; -export { FundingProgress } from './FundingProgress'; export { ProjectLinks } from './ProjectLinks'; export { TagsSection } from './TagsSection'; diff --git a/app/me/crowdfunding/[slug]/contributions/page.tsx b/app/me/crowdfunding/[slug]/contributions/page.tsx index 7f55c1838..4ac9b0e76 100644 --- a/app/me/crowdfunding/[slug]/contributions/page.tsx +++ b/app/me/crowdfunding/[slug]/contributions/page.tsx @@ -1,102 +1,41 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { getCrowdfundingProject } from '@/features/projects/api'; -import type { Crowdfunding } from '@/features/projects/types'; +import { use } from 'react'; + +import { useCampaign } from '@/features/crowdfunding'; import { ContributionsDataTable } from './contributions-data-table'; -import { ArrowLeft } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { useRouter } from 'next/navigation'; import { ContributionsMetrics } from '@/components/crowdfunding/contributions-metrics'; interface ContributionsPageProps { - params: Promise<{ - slug: string; - }>; + params: Promise<{ slug: string }>; } export default function ContributionsPage({ params }: ContributionsPageProps) { - const router = useRouter(); - const resolvedParams = use(params); - const slug = resolvedParams.slug; + const { slug } = use(params); + const { data: campaign, isLoading, error } = useCampaign(slug); - const [project, setProject] = useState(null); - const [loading, setLoading] = useState(true); + if (isLoading) { + return ( +
+
+
+ ); + } - useEffect(() => { - const fetchProject = async () => { - try { - setLoading(true); - const data = await getCrowdfundingProject(slug); - setProject(data); - } catch { - // Error handled by UI state - } finally { - setLoading(false); - } - }; + if (error || !campaign) { + return ( +
+ Failed to load contributions +
+ ); + } - fetchProject(); - }, [slug]); + const contributors = campaign.contributors ?? []; 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/me/crowdfunding/[slug]/edit/components/BasicInfoSection.tsx b/app/me/crowdfunding/[slug]/edit/components/BasicInfoSection.tsx deleted file mode 100644 index b1dfceac9..000000000 --- a/app/me/crowdfunding/[slug]/edit/components/BasicInfoSection.tsx +++ /dev/null @@ -1,233 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; -import { Button } from '@/components/ui/button'; -import { Upload, X } from 'lucide-react'; -import Image from 'next/image'; -import { uploadService } from '@/lib/api/upload'; -import { cn } from '@/lib/utils'; - -interface BasicInfoSectionProps { - data: { - title: string; - logo: string; - vision: string; - category: string; - }; - onChange: (field: string, value: any) => void; -} - -const categories = [ - 'DeFi & Finance', - 'Gaming & Metaverse', - 'Social & Community', - 'Infrastructure & Tooling', - 'AI & Machine Learning', - 'Sustainability & Impact', - 'Other', -]; - -export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) { - const [uploading, setUploading] = useState(false); - const [isDragOver, setIsDragOver] = useState(false); - - const handleLogoUpload = async ( - event: React.ChangeEvent - ) => { - const file = event.target.files?.[0]; - if (!file) return; - - setUploading(true); - try { - const result = await uploadService.uploadSingle(file); - onChange('logo', result.data.url); - } catch { - // Error handled silently - } finally { - setUploading(false); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }; - - const handleDragLeave = () => { - setIsDragOver(false); - }; - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - - const file = e.dataTransfer.files?.[0]; - if (!file) return; - - setUploading(true); - try { - const result = await uploadService.uploadSingle(file); - onChange('logo', result.data.url); - } catch { - // Error handled silently - } finally { - setUploading(false); - } - }; - - const removeLogo = () => { - onChange('logo', ''); - }; - - return ( -
- {/* Two Column Grid Layout */} -
- {/* Left Column - Title and Logo */} -
- {/* Title */} -
- - onChange('title', e.target.value)} - placeholder='Enter project name/title' - className='bg-card text-foreground placeholder:text-muted-foreground' - /> -
- - {/* Logo */} -
- -
- {data.logo ? ( -
-
- Project logo -
- -
- ) : null} - -
-
-
- - {/* Right Column - Vision and Category */} -
- {/* Vision */} -
- -