diff --git a/app/(landing)/campaigns/[slug]/page.tsx b/app/(landing)/campaigns/[slug]/page.tsx index 5d17f1ae..d8d9772c 100644 --- a/app/(landing)/campaigns/[slug]/page.tsx +++ b/app/(landing)/campaigns/[slug]/page.tsx @@ -1,11 +1,9 @@ 'use client'; -import { ProjectLayout } from '@/components/project-details/project-layout'; +import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams, notFound } from 'next/navigation'; import { reportError } from '@/lib/error-reporting'; -import { ProjectLoading } from '@/components/project-details/project-loading'; import { getCrowdfundingProject } from '@/features/projects/api'; -import { useEffect, useState } from 'react'; -import { useSearchParams, notFound } from 'next/navigation'; import { getSubmissionDetails, getHackathon, @@ -18,18 +16,37 @@ import { buildFromSubmission, } from '@/features/projects/lib/build-view-model'; +import { isApiNotFound } from '../../projects/[slug]/components/utils'; +import { HeroSection } from '../../projects/[slug]/components/hero-section'; +import { + ProjectTabs, + buildProjectTabs, + type ProjectTabValue, +} from '../../projects/[slug]/components/project-tabs'; +import { DetailsTab } from '../../projects/[slug]/components/details-tab'; +import { TeamTab } from '../../projects/[slug]/components/team-tab'; +import { MilestonesTab } from '../../projects/[slug]/components/milestones-tab'; +import { VotersTab } from '../../projects/[slug]/components/voters-tab'; +import { BackersTab } from '../../projects/[slug]/components/backers-tab'; +import { ProjectComments } from '@/components/project-details/comment-section/project-comments'; +import { + HeroSectionSkeleton, + ProjectTabsSkeleton, + DetailsTabSkeleton, +} from '../../projects/[slug]/components/skeletons'; + interface ProjectPageProps { - params: Promise<{ - slug: string; - }>; + params: Promise<{ slug: string }>; } +// ─── Content component ─────────────────────────────────────────────────────── + function ProjectContent({ id, - isSubmission = false, + isSubmission, }: { id: string; - isSubmission?: boolean; + isSubmission: boolean; }) { const [vm, setVm] = useState(null); const [error, setError] = useState(null); @@ -38,75 +55,78 @@ function ProjectContent({ useEffect(() => { let cancelled = false; - const fetchSubmission = async ( - submissionId: string - ): Promise => { - const submissionRes = await getSubmissionDetails(submissionId); - if (!submissionRes?.data) throw new Error('Submission not found'); - - const submission = submissionRes.data; - const subData = submission as unknown as Record; - - let hackathon: Hackathon | null = null; - if (subData.hackathonId) { - try { - const hackathonRes = await getHackathon( - subData.hackathonId as string - ); - hackathon = hackathonRes.data; - } catch (err) { - reportError(err, { - context: 'project-fetchHackathonDetails', - submissionId: id, - }); - } - } - - if (!hackathon) throw new Error('Hackathon details not found'); - - return buildFromSubmission( - submission as ParticipantSubmission & { members?: unknown[] }, - hackathon - ); - }; - - const fetchProjectData = async () => { + const fetchData = async () => { try { setLoading(true); setError(null); + // ── Hackathon submission path ── if (isSubmission) { - const result = await fetchSubmission(id); + const result = await fetchAsSubmission(id); if (!cancelled) setVm(result); return; } + // ── Primary path for the campaigns route: try crowdfunding first ── + // Only fall through on a real 404 — other errors (network, 5xx, rate + // limit) propagate so they aren't masked as "Project not found". try { const projectData = await getCrowdfundingProject(id); if (!cancelled && projectData) { setVm(buildFromCrowdfunding(projectData)); return; } - } catch { - const result = await fetchSubmission(id); + } catch (err) { + if (!isApiNotFound(err)) throw err; + } + + // ── Fallback: hackathon submission ── + try { + const result = await fetchAsSubmission(id); if (!cancelled) setVm(result); + return; + } catch (err) { + if (!isApiNotFound(err)) throw err; + // Nothing found at all } + + if (!cancelled) setError('Project not found'); } catch (err) { - reportError(err, { context: 'project-fetch', id }); + reportError(err, { context: 'campaigns-fetch', id }); if (!cancelled) setError('Failed to fetch project data'); } finally { if (!cancelled) setLoading(false); } }; - fetchProjectData(); + fetchData(); return () => { cancelled = true; }; }, [id, isSubmission]); + // Silent background re-fetch after pledge/cancellation — keeps current vm + // on failure so the UI never flashes empty. + const refreshData = async () => { + try { + if (isSubmission) { + const result = await fetchAsSubmission(id); + setVm(result); + return; + } + try { + const projectData = await getCrowdfundingProject(id); + if (projectData) setVm(buildFromCrowdfunding(projectData)); + } catch { + /* fail silently — existing vm stays */ + } + } catch { + /* fail silently */ + } + }; + if (loading) { - return ; + return ; } if (error || !vm) { @@ -114,25 +134,139 @@ function ProjectContent({ } return ( -
-
- + + ); +} + +function ProjectPageContent({ + vm, + isSubmission, + onRefresh, +}: { + vm: ProjectViewModel; + isSubmission: boolean; + onRefresh: () => Promise; +}) { + const tabs = useMemo(() => buildProjectTabs(vm), [vm]); + const [activeTab, setActiveTab] = useState( + tabs[0]?.value ?? 'details' + ); + + // Reset the active tab whenever the tab set changes so the selection never + // points at a tab that no longer exists. + useEffect(() => { + if (!tabs.some(t => t.value === activeTab)) { + setActiveTab(tabs[0]?.value ?? 'details'); + } + }, [tabs, activeTab]); + + return ( +
+
+ + +
+ + + {activeTab === 'details' && ( + + )} + {activeTab === 'team' && } + {activeTab === 'milestones' && } + {activeTab === 'voters' && } + {activeTab === 'backers' && } + {activeTab === 'comments' && } +
-
+ ); } -export default function ProjectPage({ params }: ProjectPageProps) { +// ─── Initial-load skeleton ─────────────────────────────────────────────────── + +function ProjectPageSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ ); +} + +// ─── Hackathon submission helper ───────────────────────────────────────────── + +async function fetchAsSubmission(id: string): Promise { + const submissionRes = await getSubmissionDetails(id); + if (!submissionRes?.data) throw new Error('Submission not found'); + + const submission = submissionRes.data; + const subData = submission as unknown as Record; + + let hackathon: Hackathon | null = null; + if (subData.hackathonId) { + try { + const hackathonRes = await getHackathon(subData.hackathonId as string); + hackathon = hackathonRes.data; + } catch (err) { + reportError(err, { + context: 'campaigns-fetchHackathonDetails', + submissionId: id, + }); + } + } + + if (!hackathon) throw new Error('Hackathon details not found'); + + return buildFromSubmission( + submission as ParticipantSubmission & { members?: unknown[] }, + hackathon + ); +} + +// ─── Page component ────────────────────────────────────────────────────────── + +export default function CampaignPage({ params }: ProjectPageProps) { const [id, setId] = useState(null); + const [paramsError, setParamsError] = useState(false); const searchParams = useSearchParams(); const isSubmission = searchParams.get('type') === 'submission'; useEffect(() => { - params.then(resolved => setId(resolved.slug)); + params + .then(resolved => setId(resolved.slug)) + .catch(err => { + reportError(err, { context: 'campaigns-page-params' }); + setParamsError(true); + }); }, [params]); + if (paramsError) { + notFound(); + } + if (!id) { - return ; + return ; } return ; diff --git a/app/(landing)/projects/[slug]/components/backer-card.tsx b/app/(landing)/projects/[slug]/components/backer-card.tsx new file mode 100644 index 00000000..85325eeb --- /dev/null +++ b/app/(landing)/projects/[slug]/components/backer-card.tsx @@ -0,0 +1,128 @@ +'use client'; + +import Image from 'next/image'; +import { Gem, Trophy } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatRelative } from './utils'; +import type { Contributor } from '@/features/projects/types'; + +export type BackerTier = 'lead' | 'top' | null; + +interface BackerCardProps { + backer: Contributor; + tier?: BackerTier; + currency?: string; + onClick?: (backer: Contributor) => void; + className?: string; +} + +const TIER_LABELS: Record, string> = { + lead: 'Lead Backer', + top: 'Top Backer', +}; + +const TIER_ICONS: Record, typeof Gem> = { + lead: Gem, + top: Trophy, +}; + +function formatAmount(amount: number, currency?: string) { + const formatted = new Intl.NumberFormat('en-US').format(amount); + return currency ? `${formatted} ${currency}` : `$${formatted}`; +} + +export function BackerCard({ + backer, + tier = null, + currency, + onClick, + className, +}: BackerCardProps) { + const TierIcon = tier ? TIER_ICONS[tier] : null; + const tierLabel = tier ? TIER_LABELS[tier] : 'Backer'; + const hasOnClick = !!onClick; + + return ( +
onClick?.(backer)} + role={hasOnClick ? 'button' : undefined} + tabIndex={hasOnClick ? 0 : undefined} + onKeyDown={ + hasOnClick + ? e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(backer); + } + } + : undefined + } + className={cn( + 'border-stepper-border bg-background-card relative flex flex-col gap-4 rounded-2xl border p-5 transition-colors', + hasOnClick && 'hover:border-primary/40 cursor-pointer', + className + )} + > + {/* Tier badge */} + {tier && ( + + {TierIcon && } + {tier === 'lead' ? 'LEAD' : 'TOP'} + + )} + + {/* Header */} +
+
+ {backer.name +
+
+

+ {backer.name || 'Anonymous'} +

+

+ {tierLabel} +

+
+
+ + {/* Stats */} +
+
+
Contribution
+
+ {formatAmount(backer.amount, currency)} +
+
+
+
Time
+
{formatRelative(backer.date)}
+
+ {backer.username && ( +
+
Handle
+
@{backer.username}
+
+ )} +
+ + {/* Quote */} + {backer.message && ( +

+ “{backer.message}” +

+ )} +
+ ); +} diff --git a/app/(landing)/projects/[slug]/components/backers-tab.tsx b/app/(landing)/projects/[slug]/components/backers-tab.tsx new file mode 100644 index 00000000..e2f6dd49 --- /dev/null +++ b/app/(landing)/projects/[slug]/components/backers-tab.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useMemo } from 'react'; +import Image from 'next/image'; +import { Gem, Globe, Heart, Rocket, ShieldCheck, Users } from 'lucide-react'; +import { useSearchParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { FundingModal } from '@/components/project-details/funding-modal'; +import { BackerCard, type BackerTier } from './backer-card'; +import { CampaignStatus, getProjectStatus } from './utils'; +import { type Contributor } from '@/features/projects/types'; +import type { ProjectViewModel } from '@/features/projects/types/view-model'; + +interface BackersTabProps { + vm: ProjectViewModel; +} + +interface RankedBacker { + contributor: Contributor; + tier: BackerTier; +} + +function rankBackers(contributors: Contributor[]): RankedBacker[] { + // Sort by amount desc; assign tiers to the top 3. + const sorted = [...contributors].sort((a, b) => b.amount - a.amount); + return sorted.map((contributor, index) => ({ + contributor, + tier: index === 0 ? 'lead' : index <= 2 ? 'top' : null, + })); +} + +export function BackersTab({ vm }: BackersTabProps) { + const searchParams = useSearchParams(); + const isSubmission = searchParams.get('type') === 'submission'; + + const contributors = vm.campaign?.contributors ?? []; + const ranked = useMemo(() => rankBackers(contributors), [contributors]); + const currency = vm.campaign?.fundingCurrency; + + // Only show the funding CTA when the campaign is actually accepting funds. + // Uses the same derived status as ProjectActions in the hero card so a + // fully-funded campaign with a stale CAMPAIGNING backend status doesn't + // get an actionable Back CTA here while the hero shows "Funded". + const canBack = + !isSubmission && + !!vm.campaign && + getProjectStatus(vm) === CampaignStatus.CAMPAIGNING; + + const handleBackerClick = (backer: Contributor) => { + // Mirrors legacy ProjectBackers — uses userId for the profile path. + if (backer.userId) { + window.open(`/profile/${backer.userId}`, '_blank'); + } + }; + + if (contributors.length === 0) { + return ( + + ); + } + + return ( +
+ {/* Header */} +
+

Project Backers

+

+ + {contributors.length}{' '} + {contributors.length === 1 ? 'backer' : 'backers'} supporting this + vision +

+
+ + {/* Backer cards grid */} +
+ {ranked.map(({ contributor, tier }) => ( + + ))} +
+ + {/* Join CTA — only when the campaign is actively accepting funds */} + {canBack && ( + r.contributor)} + /> + )} +
+ ); +} + +/* ─────────────────────── Join backers CTA ─────────────────────── */ + +function JoinBackersCard({ + vm, + totalBackers, + previewBackers, +}: { + vm: ProjectViewModel; + totalBackers: number; + previewBackers: Contributor[]; +}) { + if (!vm.campaign) return null; + const remaining = Math.max(0, totalBackers - previewBackers.length); + + return ( +
+ {/* Avatar pile */} + {previewBackers.length > 0 && ( +
+
+ {previewBackers.map(b => ( +
+ {b.name +
+ ))} + {remaining > 0 && ( +
+ +{remaining} +
+ )} +
+
+ )} + +

+ Join {totalBackers} {totalBackers === 1 ? 'other' : 'others'} and back{' '} + {vm.title} +

+

+ Support this project and help bring this vision to life. +

+ +
+ + + +
+
+ ); +} + +/* ───────────────────────── Empty state ────────────────────────── */ + +function BackersEmptyState({ + vm, + isSubmission, + canBack, +}: { + vm: ProjectViewModel; + isSubmission: boolean; + canBack: boolean; +}) { + return ( +
+ {/* Decorative gem tile */} +
+ + + + +
+ +

+ {isSubmission ? 'No backers yet' : 'Be the first to back this project!'} +

+

+ {isSubmission + ? `Hackathon submissions don't accept contributions, but you can still vote and follow ${vm.title}.` + : `Show your support by funding ${vm.title} and help bring this vision to life.`} +

+ + {canBack && vm.campaign && ( +
+ + + +
+ )} + + {/* Trust footer */} +
+ + + Secure Escrow + + + + Web3 Native + +
+
+ ); +} diff --git a/app/(landing)/projects/[slug]/components/details-tab.tsx b/app/(landing)/projects/[slug]/components/details-tab.tsx new file mode 100644 index 00000000..c5ebf607 --- /dev/null +++ b/app/(landing)/projects/[slug]/components/details-tab.tsx @@ -0,0 +1,373 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Bell, FileText, HandCoins, Info, Loader2, Share2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { FundingModal } from '@/components/project-details/funding-modal'; +import { useMarkdown } from '@/hooks/use-markdown'; +import { cn } from '@/lib/utils'; +import { CampaignStatus, getProjectStatus } from './utils'; +import { + MediaPlayer, + MediaPlayerControls, + MediaPlayerControlsOverlay, + MediaPlayerFullscreen, + MediaPlayerLoading, + MediaPlayerPlay, + MediaPlayerSeek, + MediaPlayerSeekBackward, + MediaPlayerSeekForward, + MediaPlayerTime, + MediaPlayerVideo, + MediaPlayerVolume, +} from '@/components/ui/media-player'; +import type { ProjectViewModel } from '@/features/projects/types/view-model'; + +interface DetailsTabProps { + vm: ProjectViewModel; + isSubmission?: boolean; + onRefresh?: () => void; +} + +interface OutlineItem { + id: string; + label: string; +} + +function slugify(text: string) { + return text + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); +} + +export function DetailsTab({ vm, isSubmission, onRefresh }: DetailsTabProps) { + const hasDescription = !!vm.description?.trim(); + + if (!hasDescription) { + return ; + } + + return ( + + ); +} + +function DetailsContent({ vm, isSubmission, onRefresh }: DetailsTabProps) { + const { loading, error, styledContent } = useMarkdown(vm.description, { + breaks: true, + gfm: true, + pedantic: true, + loadingDelay: 100, + }); + + const contentRef = useRef(null); + const [outline, setOutline] = useState([]); + const [activeId, setActiveId] = useState(null); + + // Build outline from rendered h2 headings + scroll-spy + useEffect(() => { + if (loading || !contentRef.current) return; + + const headings = Array.from( + contentRef.current.querySelectorAll('h2') + ) as HTMLHeadingElement[]; + + const items: OutlineItem[] = headings.map(h => { + const label = h.textContent ?? ''; + const id = h.id || slugify(label); + h.id = id; + h.style.scrollMarginTop = '96px'; + return { id, label }; + }); + + setOutline(items); + setActiveId(prev => prev ?? items[0]?.id ?? null); + + if (!items.length) return; + + const observer = new IntersectionObserver( + entries => { + const visible = entries + .filter(e => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); + if (visible[0]?.target.id) setActiveId(visible[0].target.id); + }, + { rootMargin: '-96px 0px -60% 0px', threshold: 0 } + ); + + headings.forEach(h => observer.observe(h)); + return () => observer.disconnect(); + }, [loading, vm.description]); + + const handleOutlineClick = (id: string) => { + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setActiveId(id); + } + }; + + // Match the gating used by ProjectActions in the hero card so the support + // CTA only appears when the project is actively accepting funds. A + // submission, completed campaign, or fully-funded campaign won't show it. + const canBack = + !isSubmission && + !!vm.campaign && + getProjectStatus(vm) === CampaignStatus.CAMPAIGNING; + + return ( +
+ {/* Outline sidebar */} + + + {/* Right column: markdown + video + support CTA */} +
+ {loading ? ( +
+
+ + Loading content... +
+
+ ) : error ? ( +
+

Error loading content:

+

{error}

+
+ ) : ( +
{styledContent}
+ )} + + {/* Demo video showcase — preserves the legacy media player behavior */} + {vm.demoVideo && } + + {/* Support CTA — only when the campaign is actively accepting funds */} + {canBack && } +
+
+ ); +} + +/* ────────────────────────── Demo video ────────────────────────── */ + +function getYouTubeEmbedUrl(url: string): string | null { + try { + if (url.includes('youtube.com') || url.includes('youtu.be')) { + let videoId = ''; + if (url.includes('youtu.be')) { + videoId = url.split('/').pop()?.split('?')[0] || ''; + } else if (url.includes('youtube.com/watch')) { + const urlParams = new URLSearchParams(new URL(url).search); + videoId = urlParams.get('v') || ''; + } + if (videoId) return `https://www.youtube.com/embed/${videoId}`; + } + return null; + } catch { + return null; + } +} + +function DemoVideo({ url }: { url: string }) { + const youtubeEmbedUrl = getYouTubeEmbedUrl(url); + + return ( +
+
+
+

+ Media Showcase +

+
+ + +
+ {youtubeEmbedUrl ? ( +