Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 189 additions & 55 deletions app/(landing)/campaigns/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<ProjectViewModel | null>(null);
const [error, setError] = useState<string | null>(null);
Expand All @@ -38,101 +55,218 @@ function ProjectContent({
useEffect(() => {
let cancelled = false;

const fetchSubmission = async (
submissionId: string
): Promise<ProjectViewModel> => {
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<string, unknown>;

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);
Comment on lines +63 to 66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Track the fetched source before passing isSubmission downstream.

This route can also fall back to fetchAsSubmission(id) while isSubmission stays false. When that happens, refreshData keeps querying crowdfunding and the child components still receive campaign semantics instead of submission semantics.

Also applies to: 84-87, 110-119, 137-140

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(landing)/campaigns/[slug]/page.tsx around lines 63 - 66, The route
sometimes calls fetchAsSubmission(id) but does not update the flag that
indicates the fetched source, so downstream logic still treats the data as a
campaign; update the local state that tracks source semantics before calling
setVm and before refreshData so children see submission semantics. Specifically,
when you call fetchAsSubmission(id) (and similar calls at the other locations),
set the indicator derived from isSubmission (or a new variable like
fetchedIsSubmission) to true and pass that into setVm/refreshData (or update the
state property used by child components) so the component tree receives
submission semantics consistently.

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 <ProjectLoading />;
return <ProjectPageSkeleton />;
}

if (error || !vm) {
notFound();
}

return (
<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]'>
<div className='flex-1'>
<ProjectLayout vm={vm} />
<ProjectPageContent
vm={vm}
isSubmission={isSubmission}
onRefresh={refreshData}
/>
);
}

function ProjectPageContent({
vm,
isSubmission,
onRefresh,
}: {
vm: ProjectViewModel;
isSubmission: boolean;
onRefresh: () => Promise<void>;
}) {
const tabs = useMemo(() => buildProjectTabs(vm), [vm]);
const [activeTab, setActiveTab] = useState<ProjectTabValue>(
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 (
<main className='bg-background-main-bg min-h-screen'>
<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]'>
<HeroSection
vm={vm}
isSubmission={isSubmission}
onRefresh={onRefresh}
/>

<div className='space-y-8'>
<ProjectTabs
tabs={tabs}
value={activeTab}
onValueChange={setActiveTab}
/>

{activeTab === 'details' && (
<DetailsTab
vm={vm}
isSubmission={isSubmission}
onRefresh={onRefresh}
/>
)}
{activeTab === 'team' && <TeamTab vm={vm} />}
{activeTab === 'milestones' && <MilestonesTab vm={vm} />}
{activeTab === 'voters' && <VotersTab vm={vm} />}
{activeTab === 'backers' && <BackersTab vm={vm} />}
{activeTab === 'comments' && <ProjectComments projectId={vm.id} />}
</div>
</div>
</div>
</main>
);
}

export default function ProjectPage({ params }: ProjectPageProps) {
// ─── Initial-load skeleton ───────────────────────────────────────────────────

function ProjectPageSkeleton() {
return (
<main className='bg-background-main-bg min-h-screen'>
<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]'>
<HeroSectionSkeleton />
<div className='space-y-8'>
<ProjectTabsSkeleton />
<DetailsTabSkeleton />
</div>
</div>
</main>
);
}

// ─── Hackathon submission helper ─────────────────────────────────────────────

async function fetchAsSubmission(id: string): Promise<ProjectViewModel> {
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<string, unknown>;

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<string | null>(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 <ProjectLoading />;
return <ProjectPageSkeleton />;
}

return <ProjectContent id={id} isSubmission={isSubmission} />;
Expand Down
Loading
Loading