11import { ReactElement } from "react" ;
22
33import type { GetStaticPaths , GetStaticProps } from "next" ;
4+ import { useRouter } from "next/router" ;
45
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 { CampaignBanner , CampaignDonorsTable } from "@/entities/campaign" ;
510import { CampaignLayout } from "@/layout/campaign/components/layout" ;
611import { RootLayout } from "@/layout/components/root-layout" ;
712
8- type PageProps = {
9- campaignId : number ;
13+ type SeoProps = {
1014 seoTitle : string ;
1115 seoDescription : string ;
1216 seoImage ?: string ;
1317} ;
1418
15- export default function CampaignPage ( props : PageProps ) {
16- // Content is rendered by CampaignLayout based on tab query param
17- // This component just provides the SEO wrapper
19+ export default function CampaignPage ( props : SeoProps ) {
20+ const router = useRouter ( ) ;
21+ const { campaignId } = router . query as { campaignId : string } ;
22+
1823 return (
1924 < RootLayout title = { props . seoTitle } description = { props . seoDescription } image = { props . seoImage } >
20- { /* Content rendered by CampaignLayout */ }
21- < > < />
25+ < CampaignBanner campaignId = { parseInt ( campaignId ) } />
26+ < CampaignDonorsTable campaignId = { parseInt ( campaignId ) } />
2227 </ RootLayout >
2328 ) ;
2429}
@@ -27,88 +32,97 @@ CampaignPage.getLayout = function getLayout(page: ReactElement) {
2732 return < CampaignLayout > { page } </ CampaignLayout > ;
2833} ;
2934
30- // Default SEO values
31- const DEFAULT_SEO = {
32- title : "Potlock | Fund Public Goods" ,
33- description :
34- "Discover and fund public goods projects on NEAR Protocol. Support open source, community initiatives, and impactful projects." ,
35- image : "https://app.potlock.org/assets/images/meta-image.png" ,
36- } ;
37-
38- // Simple HTML strip function
39- const stripHtmlTags = ( html : string | undefined | null ) : string => {
40- if ( ! html ) return "" ;
41- return html . replace ( / < [ ^ > ] * > / g, "" ) . trim ( ) ;
42- } ;
43-
44- // ISR: No build-time pre-generation to prevent timeouts
45- // All pages generated on-demand when first requested, then cached
35+ // Pre-generate the most popular campaigns at build time
4636export const getStaticPaths : GetStaticPaths = async ( ) => {
47- return {
48- paths : [ ] , // No pre-generation at build time
49- fallback : "blocking" , // Generate on first visit, then cache with ISR
50- } ;
51- } ;
37+ try {
38+ // Fetch campaigns to get IDs for pre-generation with timeout
39+ const res = await fetchWithTimeout (
40+ "https://dev.potlock.io/api/v1/campaigns?limit=50" ,
41+ { } ,
42+ 10000 , // 10 second timeout
43+ ) ;
5244
53- // ISR: Fetch campiagn data and cache with 2-minute revalidation
54- export const getStaticProps : GetStaticProps < PageProps > = async ( { params } ) => {
55- const campaignId = params ?. campaignId as string ;
45+ if ( ! res . ok ) throw new Error ( `Failed to fetch campaigns: ${ res . status } ` ) ;
46+ const campaigns = await res . json ( ) ;
5647
57- if ( ! campaignId || isNaN ( Number ( campaignId ) ) ) {
58- return { notFound : true } ;
59- }
48+ // Generate paths for the first 50 campaigns (most recent/active)
49+ const paths =
50+ campaigns . data ?. map ( ( campaign : any ) => ( {
51+ params : { campaignId : campaign . on_chain_id . toString ( ) } ,
52+ } ) ) || [ ] ;
6053
61- const numericCampaignId = parseInt ( campaignId , 10 ) ;
54+ return {
55+ paths,
56+ fallback : "blocking" , // Generate new pages on-demand if not pre-built
57+ } ;
58+ } catch ( error ) {
59+ console . error ( "Error generating static paths:" , error ) ;
60+ // Return empty paths but still allow blocking fallback for on-demand generation
61+ return {
62+ paths : [ ] ,
63+ fallback : "blocking" ,
64+ } ;
65+ }
66+ } ;
6267
68+ // Pre-build each campaign page with its data
69+ export const getStaticProps : GetStaticProps < SeoProps > = async ( { params } ) => {
6370 try {
64- const controller = new AbortController ( ) ;
65- const timeoutId = setTimeout ( ( ) => controller . abort ( ) , 8000 ) ;
71+ const campaignId = params ?. campaignId as string ;
6672
67- const response = await fetch (
73+ if ( ! campaignId ) {
74+ return {
75+ notFound : true ,
76+ } ;
77+ }
78+
79+ // Fetch with timeout to prevent server timeouts
80+ const res = await fetchWithTimeout (
6881 `https://dev.potlock.io/api/v1/campaigns/${ encodeURIComponent ( campaignId ) } ` ,
69- { signal : controller . signal } ,
82+ { } ,
83+ 10000 , // 10 second timeout
7084 ) ;
7185
72- clearTimeout ( timeoutId ) ;
73-
74- if ( response . status === 404 ) {
75- return { notFound : true } ;
86+ // If API fails for any reason (including 404), throw error to trigger fallback props
87+ // This ensures page always renders, even if SEO data is unavailable
88+ if ( ! res . ok ) {
89+ throw new Error ( `Failed to fetch campaign: ${ res . status } ` ) ;
7690 }
7791
78- if ( ! response . ok ) {
79- return {
80- props : {
81- campaignId : numericCampaignId ,
82- seoTitle : `Campaign` ,
83- seoDescription : DEFAULT_SEO . description ,
84- seoImage : DEFAULT_SEO . image ,
85- } ,
86- revalidate : 60 , // Retry sooner on error
87- } ;
92+ let campaign ;
93+
94+ try {
95+ campaign = await res . json ( ) ;
96+ } catch ( jsonError ) {
97+ console . error ( "Error parsing campaign JSON:" , jsonError ) ;
98+ throw new Error ( "Invalid campaign data format" ) ;
8899 }
89100
90- const campaign = await response . json ( ) ;
101+ const seoTitle = campaign ?. name ?? `Campaign ${ campaignId } ` ;
102+
103+ const seoDescription = stripHtml ( campaign ?. description ) ?? "Support this campaign on Potlock." ;
104+
105+ // Use cover_image_url field which is the correct field for campaign images
106+ const seoImage = campaign ?. cover_image_url ?? APP_METADATA . openGraph . images . url ;
91107
92108 return {
93- props : {
94- campaignId : numericCampaignId ,
95- seoTitle : campaign ?. name || `Campaign` ,
96- seoDescription : stripHtmlTags ( campaign ?. description ) || DEFAULT_SEO . description ,
97- seoImage : campaign ?. cover_image_url || DEFAULT_SEO . image ,
98- } ,
99- revalidate : 120 , // Revalidate every 2 minutes
109+ props : { seoTitle, seoDescription, seoImage } ,
110+ // Revalidate every 5 minutes (300 seconds) to keep data fresh
111+ revalidate : 300 ,
100112 } ;
101113 } catch ( error ) {
102- console . error ( ` Error fetching campaign ${ campaignId } :` , error ) ;
114+ console . error ( " Error generating static props:" , error ) ;
103115
116+ // Return fallback props instead of throwing error to prevent 500
117+ // This allows the page to render with default SEO data
104118 return {
105119 props : {
106- campaignId : numericCampaignId ,
107- seoTitle : `Campaign` ,
108- seoDescription : DEFAULT_SEO . description ,
109- seoImage : DEFAULT_SEO . image ,
120+ seoTitle : `Campaign ${ params ?. campaignId || "" } ` ,
121+ seoDescription : APP_METADATA . description ,
122+ seoImage : APP_METADATA . openGraph . images . url ,
110123 } ,
111- revalidate : 60 , // Retry sooner on error
124+ // Shorter revalidate for error cases to retry sooner
125+ revalidate : 60 ,
112126 } ;
113127 }
114128} ;
0 commit comments