Skip to content

Commit 11cdd3d

Browse files
Benjtalkshow0xdevcollinsclaude
authored
Refactor project details page (#527)
* feat: Implement a comprehensive project and campaign creation flow, including dedicated public campaign pages. * refactor(projects): redesign project page UI and retire legacy stack Replace the legacy ProjectLayout + ProjectSidebar shell with a new HeroSection + tabbed layout colocated under app/(landing)/projects/[slug]/components. All existing functionality is preserved: vote/back/follow/share actions, realtime vote counts, milestone submissions and disputes, backers list, and the existing comment system continues to render via ProjectComments. The campaigns/[slug] route is migrated to the same components so both routes share one source of truth, and the now-unused legacy files under components/project-details/ (project-layout, project-sidebar/*, project-details, project-team, project-about, project-backers/*, project-voters/*, project-loading, CampaignEndBanner, ProjectFundEscrow) are deleted. comment-section, funding-modal, and project-milestone are kept since they are still consumed by the new UI and standalone routes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(projects): address review findings on project details refactor - Distinguish API 404 from transport/5xx errors in both project/campaign fetch flows so backend incidents surface as "Failed to fetch" instead of being masked as "Project not found" (new isApiNotFound helper). - Reset activeTab when the tab set changes so the selection never points at a tab that no longer exists. - Handle promise rejection from the params prop with notFound() instead of leaving the page stuck on the skeleton. - Backers tab: gate the funding CTA on getProjectStatus(vm) so a fully funded campaign with a stale CAMPAIGNING backend status no longer shows an actionable Back CTA while the hero shows "Funded". - Milestones tab: map resubmission_required (and variants) to in-progress so resubmittable rows are styled and counted correctly; harden formatDate with an explicit Number.isFinite check. - Project actions: subscribe useVoteRealtime + getVoteCounts to the derived entityType so submission votes don't get routed through the campaign channel; merge realtime vote payloads so a teammate's update cannot wipe the viewer's userVote selection; split the cancel flow into separate on-chain cancel and off-chain delete steps with distinct toasts and a reportError on the delete failure. - Voters tab: implement Load More pagination with totalItems tracking and id-based dedupe so the list is no longer silently capped at 20; merge realtime vote payloads so onVoteDeleted no longer force-clears userVote. - Team member card: only react to Enter/Space when the card itself is focused so nested profile/email links are not stolen. - Team tab: drop the invalid member.email vs creator.username comparison in matchesCreator (VMCreator has no email field). - Deduplicate formatRelative into utils.ts (shared between backer-card and voter-row) and add a Number.isFinite guard. - Delete the now-dead components/project-details/project-milestone/index.tsx (no consumers after the project layout retirement). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: minor fixes * fix: minor fixes --------- Co-authored-by: Collins Ikechukwu <collinschristroa@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 96133c3 commit 11cdd3d

39 files changed

Lines changed: 3944 additions & 3119 deletions

app/(landing)/campaigns/[slug]/page.tsx

Lines changed: 189 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
'use client';
22

3-
import { ProjectLayout } from '@/components/project-details/project-layout';
3+
import { useEffect, useMemo, useState } from 'react';
4+
import { useSearchParams, notFound } from 'next/navigation';
45
import { reportError } from '@/lib/error-reporting';
5-
import { ProjectLoading } from '@/components/project-details/project-loading';
66
import { getCrowdfundingProject } from '@/features/projects/api';
7-
import { useEffect, useState } from 'react';
8-
import { useSearchParams, notFound } from 'next/navigation';
97
import {
108
getSubmissionDetails,
119
getHackathon,
@@ -18,18 +16,37 @@ import {
1816
buildFromSubmission,
1917
} from '@/features/projects/lib/build-view-model';
2018

19+
import { isApiNotFound } from '../../projects/[slug]/components/utils';
20+
import { HeroSection } from '../../projects/[slug]/components/hero-section';
21+
import {
22+
ProjectTabs,
23+
buildProjectTabs,
24+
type ProjectTabValue,
25+
} from '../../projects/[slug]/components/project-tabs';
26+
import { DetailsTab } from '../../projects/[slug]/components/details-tab';
27+
import { TeamTab } from '../../projects/[slug]/components/team-tab';
28+
import { MilestonesTab } from '../../projects/[slug]/components/milestones-tab';
29+
import { VotersTab } from '../../projects/[slug]/components/voters-tab';
30+
import { BackersTab } from '../../projects/[slug]/components/backers-tab';
31+
import { ProjectComments } from '@/components/project-details/comment-section/project-comments';
32+
import {
33+
HeroSectionSkeleton,
34+
ProjectTabsSkeleton,
35+
DetailsTabSkeleton,
36+
} from '../../projects/[slug]/components/skeletons';
37+
2138
interface ProjectPageProps {
22-
params: Promise<{
23-
slug: string;
24-
}>;
39+
params: Promise<{ slug: string }>;
2540
}
2641

42+
// ─── Content component ───────────────────────────────────────────────────────
43+
2744
function ProjectContent({
2845
id,
29-
isSubmission = false,
46+
isSubmission,
3047
}: {
3148
id: string;
32-
isSubmission?: boolean;
49+
isSubmission: boolean;
3350
}) {
3451
const [vm, setVm] = useState<ProjectViewModel | null>(null);
3552
const [error, setError] = useState<string | null>(null);
@@ -38,101 +55,218 @@ function ProjectContent({
3855
useEffect(() => {
3956
let cancelled = false;
4057

41-
const fetchSubmission = async (
42-
submissionId: string
43-
): Promise<ProjectViewModel> => {
44-
const submissionRes = await getSubmissionDetails(submissionId);
45-
if (!submissionRes?.data) throw new Error('Submission not found');
46-
47-
const submission = submissionRes.data;
48-
const subData = submission as unknown as Record<string, unknown>;
49-
50-
let hackathon: Hackathon | null = null;
51-
if (subData.hackathonId) {
52-
try {
53-
const hackathonRes = await getHackathon(
54-
subData.hackathonId as string
55-
);
56-
hackathon = hackathonRes.data;
57-
} catch (err) {
58-
reportError(err, {
59-
context: 'project-fetchHackathonDetails',
60-
submissionId: id,
61-
});
62-
}
63-
}
64-
65-
if (!hackathon) throw new Error('Hackathon details not found');
66-
67-
return buildFromSubmission(
68-
submission as ParticipantSubmission & { members?: unknown[] },
69-
hackathon
70-
);
71-
};
72-
73-
const fetchProjectData = async () => {
58+
const fetchData = async () => {
7459
try {
7560
setLoading(true);
7661
setError(null);
7762

63+
// ── Hackathon submission path ──
7864
if (isSubmission) {
79-
const result = await fetchSubmission(id);
65+
const result = await fetchAsSubmission(id);
8066
if (!cancelled) setVm(result);
8167
return;
8268
}
8369

70+
// ── Primary path for the campaigns route: try crowdfunding first ──
71+
// Only fall through on a real 404 — other errors (network, 5xx, rate
72+
// limit) propagate so they aren't masked as "Project not found".
8473
try {
8574
const projectData = await getCrowdfundingProject(id);
8675
if (!cancelled && projectData) {
8776
setVm(buildFromCrowdfunding(projectData));
8877
return;
8978
}
90-
} catch {
91-
const result = await fetchSubmission(id);
79+
} catch (err) {
80+
if (!isApiNotFound(err)) throw err;
81+
}
82+
83+
// ── Fallback: hackathon submission ──
84+
try {
85+
const result = await fetchAsSubmission(id);
9286
if (!cancelled) setVm(result);
87+
return;
88+
} catch (err) {
89+
if (!isApiNotFound(err)) throw err;
90+
// Nothing found at all
9391
}
92+
93+
if (!cancelled) setError('Project not found');
9494
} catch (err) {
95-
reportError(err, { context: 'project-fetch', id });
95+
reportError(err, { context: 'campaigns-fetch', id });
9696
if (!cancelled) setError('Failed to fetch project data');
9797
} finally {
9898
if (!cancelled) setLoading(false);
9999
}
100100
};
101101

102-
fetchProjectData();
102+
fetchData();
103103
return () => {
104104
cancelled = true;
105105
};
106106
}, [id, isSubmission]);
107107

108+
// Silent background re-fetch after pledge/cancellation — keeps current vm
109+
// on failure so the UI never flashes empty.
110+
const refreshData = async () => {
111+
try {
112+
if (isSubmission) {
113+
const result = await fetchAsSubmission(id);
114+
setVm(result);
115+
return;
116+
}
117+
try {
118+
const projectData = await getCrowdfundingProject(id);
119+
if (projectData) setVm(buildFromCrowdfunding(projectData));
120+
} catch {
121+
/* fail silently — existing vm stays */
122+
}
123+
} catch {
124+
/* fail silently */
125+
}
126+
};
127+
108128
if (loading) {
109-
return <ProjectLoading />;
129+
return <ProjectPageSkeleton />;
110130
}
111131

112132
if (error || !vm) {
113133
notFound();
114134
}
115135

116136
return (
117-
<div className='mx-auto flex min-h-screen max-w-[1440px] flex-col space-y-10 px-4 py-4 sm:space-y-[60px] sm:px-6 sm:py-5 md:space-y-20 md:px-[50px] lg:px-[80px] xl:px-[100px] 2xl:max-w-[1800px] 2xl:px-[120px]'>
118-
<div className='flex-1'>
119-
<ProjectLayout vm={vm} />
137+
<ProjectPageContent
138+
vm={vm}
139+
isSubmission={isSubmission}
140+
onRefresh={refreshData}
141+
/>
142+
);
143+
}
144+
145+
function ProjectPageContent({
146+
vm,
147+
isSubmission,
148+
onRefresh,
149+
}: {
150+
vm: ProjectViewModel;
151+
isSubmission: boolean;
152+
onRefresh: () => Promise<void>;
153+
}) {
154+
const tabs = useMemo(() => buildProjectTabs(vm), [vm]);
155+
const [activeTab, setActiveTab] = useState<ProjectTabValue>(
156+
tabs[0]?.value ?? 'details'
157+
);
158+
159+
// Reset the active tab whenever the tab set changes so the selection never
160+
// points at a tab that no longer exists.
161+
useEffect(() => {
162+
if (!tabs.some(t => t.value === activeTab)) {
163+
setActiveTab(tabs[0]?.value ?? 'details');
164+
}
165+
}, [tabs, activeTab]);
166+
167+
return (
168+
<main className='bg-background-main-bg min-h-screen'>
169+
<div className='mx-auto max-w-[1440px] space-y-8 px-4 py-6 sm:space-y-10 sm:px-6 sm:py-8 md:px-[50px] lg:px-[80px] xl:px-[100px] 2xl:max-w-[1800px] 2xl:px-[120px]'>
170+
<HeroSection
171+
vm={vm}
172+
isSubmission={isSubmission}
173+
onRefresh={onRefresh}
174+
/>
175+
176+
<div className='space-y-8'>
177+
<ProjectTabs
178+
tabs={tabs}
179+
value={activeTab}
180+
onValueChange={setActiveTab}
181+
/>
182+
183+
{activeTab === 'details' && (
184+
<DetailsTab
185+
vm={vm}
186+
isSubmission={isSubmission}
187+
onRefresh={onRefresh}
188+
/>
189+
)}
190+
{activeTab === 'team' && <TeamTab vm={vm} />}
191+
{activeTab === 'milestones' && <MilestonesTab vm={vm} />}
192+
{activeTab === 'voters' && <VotersTab vm={vm} />}
193+
{activeTab === 'backers' && <BackersTab vm={vm} />}
194+
{activeTab === 'comments' && <ProjectComments projectId={vm.id} />}
195+
</div>
120196
</div>
121-
</div>
197+
</main>
122198
);
123199
}
124200

125-
export default function ProjectPage({ params }: ProjectPageProps) {
201+
// ─── Initial-load skeleton ───────────────────────────────────────────────────
202+
203+
function ProjectPageSkeleton() {
204+
return (
205+
<main className='bg-background-main-bg min-h-screen'>
206+
<div className='mx-auto max-w-[1440px] space-y-8 px-4 py-6 sm:space-y-10 sm:px-6 sm:py-8 md:px-[50px] lg:px-[80px] xl:px-[100px] 2xl:max-w-[1800px] 2xl:px-[120px]'>
207+
<HeroSectionSkeleton />
208+
<div className='space-y-8'>
209+
<ProjectTabsSkeleton />
210+
<DetailsTabSkeleton />
211+
</div>
212+
</div>
213+
</main>
214+
);
215+
}
216+
217+
// ─── Hackathon submission helper ─────────────────────────────────────────────
218+
219+
async function fetchAsSubmission(id: string): Promise<ProjectViewModel> {
220+
const submissionRes = await getSubmissionDetails(id);
221+
if (!submissionRes?.data) throw new Error('Submission not found');
222+
223+
const submission = submissionRes.data;
224+
const subData = submission as unknown as Record<string, unknown>;
225+
226+
let hackathon: Hackathon | null = null;
227+
if (subData.hackathonId) {
228+
try {
229+
const hackathonRes = await getHackathon(subData.hackathonId as string);
230+
hackathon = hackathonRes.data;
231+
} catch (err) {
232+
reportError(err, {
233+
context: 'campaigns-fetchHackathonDetails',
234+
submissionId: id,
235+
});
236+
}
237+
}
238+
239+
if (!hackathon) throw new Error('Hackathon details not found');
240+
241+
return buildFromSubmission(
242+
submission as ParticipantSubmission & { members?: unknown[] },
243+
hackathon
244+
);
245+
}
246+
247+
// ─── Page component ──────────────────────────────────────────────────────────
248+
249+
export default function CampaignPage({ params }: ProjectPageProps) {
126250
const [id, setId] = useState<string | null>(null);
251+
const [paramsError, setParamsError] = useState(false);
127252
const searchParams = useSearchParams();
128253
const isSubmission = searchParams.get('type') === 'submission';
129254

130255
useEffect(() => {
131-
params.then(resolved => setId(resolved.slug));
256+
params
257+
.then(resolved => setId(resolved.slug))
258+
.catch(err => {
259+
reportError(err, { context: 'campaigns-page-params' });
260+
setParamsError(true);
261+
});
132262
}, [params]);
133263

264+
if (paramsError) {
265+
notFound();
266+
}
267+
134268
if (!id) {
135-
return <ProjectLoading />;
269+
return <ProjectPageSkeleton />;
136270
}
137271

138272
return <ProjectContent id={id} isSubmission={isSubmission} />;

0 commit comments

Comments
 (0)