Skip to content

Commit c8ce980

Browse files
authored
Merge pull request #536 from PotLock/staging
Staging to prod
2 parents a4a1058 + 9a69b2e commit c8ce980

File tree

4 files changed

+305
-93
lines changed

4 files changed

+305
-93
lines changed
Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
1-
import { useCallback, useMemo, useState } from "react";
1+
import { useCallback, useState } from "react";
22

3+
import Link from "next/link";
34
import { useRouter } from "next/router";
45

56
import { PageWithBanner } from "@/common/ui/layout/components";
67
import { TabOption } from "@/common/ui/layout/types";
78
import { cn } from "@/common/ui/layout/utils";
8-
import { CampaignBanner, CampaignDonorsTable, CampaignSettings } from "@/entities/campaign";
9+
import { CampaignBanner } from "@/entities/campaign";
910

10-
const CAMPAIGN_TABS: { label: string; id: string }[] = [
11+
const CAMPAIGN_TAB_ROUTES: TabOption[] = [
1112
{
1213
label: "Donation History",
1314
id: "leaderboard",
15+
href: "/leaderboard",
1416
},
15-
{ label: "Settings", id: "settings" },
17+
{ label: "Settings", id: "settings", href: "/settings" },
1618
];
1719

18-
type TabsProps = {
19-
options: { label: string; id: string }[];
20+
type Props = {
21+
options: TabOption[];
2022
selectedTab: string;
21-
onSelect: (tabId: string) => void;
23+
onSelect?: (tabId: string) => void;
24+
asLink?: boolean;
2225
};
2326

24-
const Tabs = ({ options, selectedTab, onSelect }: TabsProps) => {
27+
const Tabs = ({ options, selectedTab, onSelect, asLink }: Props) => {
28+
const _selectedTab = selectedTab || options[0].id;
29+
30+
const router = useRouter();
31+
const { campaignId: campaignIdParam } = router.query;
32+
33+
const campaignId = typeof campaignIdParam === "string" ? campaignIdParam : campaignIdParam?.at(0);
34+
2535
return (
2636
<div className="mb-8 flex w-full flex-row flex-wrap gap-2">
2737
<div className="w-full px-2 md:px-8">
@@ -32,13 +42,35 @@ const Tabs = ({ options, selectedTab, onSelect }: TabsProps) => {
3242
)}
3343
>
3444
{options.map((option) => {
35-
const selected = option.id === selectedTab;
45+
const selected = option.id == _selectedTab;
46+
47+
if (asLink) {
48+
return (
49+
<Link
50+
href={`/campaign/${campaignId}${option.href}`}
51+
prefetch
52+
key={option.id}
53+
className={`font-500 border-b-solid transition-duration-300 whitespace-nowrap border-b-[2px] px-4 py-[10px] text-sm text-[#7b7b7b] transition-all hover:border-b-[#292929] hover:text-[#292929] ${selected ? "border-b-[#292929] text-[#292929]" : "border-b-[transparent]"}`}
54+
onClick={() => {
55+
if (onSelect) {
56+
onSelect(option.id);
57+
}
58+
}}
59+
>
60+
{option.label}
61+
</Link>
62+
);
63+
}
3664

3765
return (
3866
<button
3967
key={option.id}
4068
className={`font-500 border-b-solid transition-duration-300 whitespace-nowrap border-b-[2px] px-4 py-[10px] text-sm text-[#7b7b7b] transition-all hover:border-b-[#292929] hover:text-[#292929] ${selected ? "border-b-[#292929] text-[#292929]" : "border-b-[transparent]"}`}
41-
onClick={() => onSelect(option.id)}
69+
onClick={() => {
70+
if (onSelect) {
71+
onSelect(option.id);
72+
}
73+
}}
4274
>
4375
{option.label}
4476
</button>
@@ -56,63 +88,31 @@ type ReactLayoutProps = {
5688

5789
export const CampaignLayout: React.FC<ReactLayoutProps> = ({ children }) => {
5890
const router = useRouter();
59-
const { campaignId, tab } = router.query as { campaignId: string; tab?: string };
60-
61-
// Derive active tab directly from URL - no state needed
62-
const activeTab = useMemo(() => {
63-
if (tab && CAMPAIGN_TABS.find((t) => t.id === tab)) {
64-
return tab;
65-
}
66-
67-
return CAMPAIGN_TABS[0].id;
68-
}, [tab]);
69-
70-
// Track if user has manually changed tabs (to prevent URL sync issues)
71-
const [userSelectedTab, setUserSelectedTab] = useState<string | null>(null);
72-
73-
// Use userSelectedTab if set, otherwise use URL-derived activeTab
74-
const currentTab = userSelectedTab ?? activeTab;
91+
const { campaignId } = router.query as { campaignId: string };
92+
const tabs = CAMPAIGN_TAB_ROUTES;
7593

76-
const handleTabChange = useCallback(
77-
(tabId: string) => {
78-
if (tabId === currentTab) return;
79-
80-
setUserSelectedTab(tabId);
81-
82-
// Update URL without triggering Next.js navigation
83-
const newUrl = `/campaign/${campaignId}?tab=${tabId}`;
84-
85-
window.history.replaceState({ ...window.history.state, as: newUrl, url: newUrl }, "", newUrl);
86-
},
87-
[campaignId, currentTab],
94+
const [selectedTab, setSelectedTab] = useState(
95+
tabs.find((tab) => router.pathname.includes(tab.href)) || tabs[0],
8896
);
8997

90-
const numericCampaignId = parseInt(campaignId || "0", 10);
91-
92-
// Render content based on current tab
93-
const renderTabContent = () => {
94-
if (currentTab === "settings") {
95-
return <CampaignSettings campaignId={numericCampaignId} />;
96-
}
97-
98-
return <CampaignDonorsTable campaignId={numericCampaignId} />;
99-
};
100-
101-
// Don't render until we have a campaignId
102-
if (!campaignId) {
103-
return null;
104-
}
98+
const handleSelectedTab = useCallback(
99+
(tabId: string) => setSelectedTab(tabs.find((tabRoute) => tabRoute.id === tabId)!),
100+
[tabs],
101+
);
105102

106103
return (
107104
<PageWithBanner>
108105
<div className="md:p-8">
109-
<CampaignBanner campaignId={numericCampaignId} />
106+
<CampaignBanner campaignId={parseInt(campaignId)} />
110107
</div>
111108

112-
<Tabs options={CAMPAIGN_TABS} selectedTab={currentTab} onSelect={handleTabChange} />
113-
<div className="flex w-full flex-row flex-wrap gap-2 md:px-8">{renderTabContent()}</div>
109+
<Tabs
110+
asLink
111+
options={tabs}
112+
selectedTab={selectedTab.id}
113+
onSelect={(tabId: string) => handleSelectedTab(tabId)}
114+
/>
115+
<div className="flex w-full flex-row flex-wrap gap-2 md:px-8">{children}</div>
114116
</PageWithBanner>
115117
);
116118
};
117-
118-
export { CAMPAIGN_TABS };

src/middleware.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextRequest, NextResponse } from "next/server";
22

3-
import { rootPathnames } from "./navigation";
3+
import { rootPathnames, routeSelectors } from "./navigation";
44

55
export async function middleware(request: NextRequest) {
66
const { pathname } = request.nextUrl;
@@ -33,6 +33,18 @@ export async function middleware(request: NextRequest) {
3333
url.pathname = `${url.pathname}home`;
3434
return NextResponse.rewrite(url);
3535
}
36+
} else if (pathname.startsWith(`${rootPathnames.CAMPAIGN}/`)) {
37+
try {
38+
const campaignIdOrZero = parseInt(pathname.split("/").at(-1) ?? `${0}`, 10);
39+
40+
if (!isNaN(campaignIdOrZero) && campaignIdOrZero !== 0) {
41+
return NextResponse.rewrite(
42+
new URL(routeSelectors.CAMPAIGN_BY_ID_LEADERBOARD(campaignIdOrZero), request.url),
43+
);
44+
}
45+
} finally {
46+
/* empty */
47+
}
3648
}
3749

3850
return NextResponse.next();
Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,125 @@
1-
import type { GetServerSideProps } from "next";
1+
import { ReactElement } from "react";
22

3-
// This page only exists to redirect to the main campaign page with tab=leaderboard
4-
// The actual content is rendered by the main index.tsx page
3+
import type { GetStaticPaths, GetStaticProps } from "next";
4+
import { useRouter } from "next/router";
55

6-
export default function CampaignLeaderboardPage() {
7-
// This component should never render because getServerSideProps redirects
8-
return null;
6+
import { APP_METADATA } from "@/common/constants";
7+
import { stripHtml } from "@/common/lib/datetime";
8+
import { fetchWithTimeout } from "@/common/lib/fetch-with-timeout";
9+
import { CampaignDonorsTable } from "@/entities/campaign";
10+
import { CampaignLayout } from "@/layout/campaign/components/layout";
11+
import { RootLayout } from "@/layout/components/root-layout";
12+
13+
type SeoProps = {
14+
seoTitle: string;
15+
seoDescription: string;
16+
seoImage?: string;
17+
};
18+
19+
export default function CampaignLeaderboardPage(props: SeoProps) {
20+
const router = useRouter();
21+
const { campaignId } = router.query as { campaignId: string };
22+
23+
return (
24+
<RootLayout title={props.seoTitle} description={props.seoDescription} image={props.seoImage}>
25+
<CampaignDonorsTable campaignId={parseInt(campaignId)} />
26+
</RootLayout>
27+
);
928
}
1029

11-
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
12-
const campaignId = params?.campaignId as string;
30+
CampaignLeaderboardPage.getLayout = function getLayout(page: ReactElement) {
31+
return <CampaignLayout>{page}</CampaignLayout>;
32+
};
33+
34+
// Pre-generate the most popular campaigns at build time
35+
export const getStaticPaths: GetStaticPaths = async () => {
36+
try {
37+
// Fetch campaigns to get IDs for pre-generation with timeout
38+
const res = await fetchWithTimeout(
39+
"https://dev.potlock.io/api/v1/campaigns?limit=50",
40+
{},
41+
8000, // 8 second timeout
42+
);
43+
44+
if (!res.ok) throw new Error(`Failed to fetch campaigns: ${res.status}`);
45+
const campaigns = await res.json();
46+
47+
// Generate paths for the first 50 campaigns (most recent/active)
48+
const paths =
49+
campaigns.data?.map((campaign: any) => ({
50+
params: { campaignId: campaign.on_chain_id.toString() },
51+
})) || [];
1352

14-
if (!campaignId) {
15-
return { notFound: true };
53+
return {
54+
paths,
55+
fallback: "blocking", // Generate new pages on-demand if not pre-built
56+
};
57+
} catch (error) {
58+
console.error("Error generating static paths:", error);
59+
// Return empty paths but still allow blocking fallback for on-demand generation
60+
return {
61+
paths: [],
62+
fallback: "blocking",
63+
};
1664
}
65+
};
66+
67+
// Pre-build each campaign page with its data
68+
export const getStaticProps: GetStaticProps<SeoProps> = async ({ params }) => {
69+
try {
70+
const campaignId = params?.campaignId as string;
1771

18-
// Server-side redirect to main campaign page with tab query param
19-
return {
20-
redirect: {
21-
destination: `/campaign/${campaignId}?tab=leaderboard`,
22-
permanent: true, // Use permanent redirect for SEO
23-
},
24-
};
72+
if (!campaignId) {
73+
return {
74+
notFound: true,
75+
};
76+
}
77+
78+
// Fetch with timeout to prevent server timeouts
79+
const res = await fetchWithTimeout(
80+
`https://dev.potlock.io/api/v1/campaigns/${encodeURIComponent(campaignId)}`,
81+
{},
82+
8000, // 8 second timeout
83+
);
84+
85+
if (!res.ok) {
86+
throw new Error(`Failed to fetch campaign: ${res.status}`);
87+
}
88+
89+
let campaign;
90+
91+
try {
92+
campaign = await res.json();
93+
} catch (jsonError) {
94+
console.error("Error parsing campaign JSON:", jsonError);
95+
throw new Error("Invalid campaign data format");
96+
}
97+
98+
const seoTitle = campaign?.name ?? `Campaign ${campaignId}`;
99+
100+
const seoDescription = stripHtml(campaign?.description) ?? "Support this campaign on Potlock.";
101+
102+
// Use cover_image_url field which is the correct field for campaign images
103+
const seoImage = campaign?.cover_image_url ?? APP_METADATA.openGraph.images.url;
104+
105+
return {
106+
props: { seoTitle, seoDescription, seoImage },
107+
// Revalidate every 5 minutes (300 seconds) to keep data fresh
108+
revalidate: 300,
109+
};
110+
} catch (error) {
111+
console.error("Error generating static props:", error);
112+
113+
// Return fallback props instead of throwing error to prevent 500
114+
// This allows the page to render with default SEO data
115+
return {
116+
props: {
117+
seoTitle: `Campaign ${params?.campaignId || ""}`,
118+
seoDescription: APP_METADATA.description,
119+
seoImage: APP_METADATA.openGraph.images.url,
120+
},
121+
// Shorter revalidate for error cases to retry sooner
122+
revalidate: 60,
123+
};
124+
}
25125
};

0 commit comments

Comments
 (0)