Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
212 changes: 159 additions & 53 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,36 @@ import {
buildFromSubmission,
} from '@/features/projects/lib/build-view-model';

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,91 +54,181 @@ 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 ──
try {
const projectData = await getCrowdfundingProject(id);
if (!cancelled && projectData) {
setVm(buildFromCrowdfunding(projectData));
return;
}
} catch {
const result = await fetchSubmission(id);
// Not a crowdfunding campaign — fall through to submission
}

// ── Fallback: hackathon submission ──
try {
const result = await fetchAsSubmission(id);
if (!cancelled) setVm(result);
return;
} catch {
// 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'
);

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} />}
{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 searchParams = useSearchParams();
const isSubmission = searchParams.get('type') === 'submission';
Expand All @@ -132,7 +238,7 @@ export default function ProjectPage({ params }: ProjectPageProps) {
}, [params]);

if (!id) {
return <ProjectLoading />;
return <ProjectPageSkeleton />;
}

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