Skip to content

Commit a4a1058

Browse files
authored
Merge pull request #534 from PotLock/staging
Staging to prod
2 parents 8303532 + 89f74e1 commit a4a1058

File tree

2 files changed

+138
-120
lines changed

2 files changed

+138
-120
lines changed

next.config.js

Lines changed: 57 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,57 @@
1-
/** @type {import('next').NextConfig} */
2-
const nextConfig = {
3-
reactStrictMode: true,
4-
5-
async redirects() {
6-
return [
7-
{
8-
source: "/((?!_next).*)js",
9-
destination: "/404",
10-
permanent: false,
11-
},
12-
];
13-
},
14-
15-
images: {
16-
// allow external source without limiting it to specific domains
17-
remotePatterns: [
18-
{
19-
protocol: "https",
20-
hostname: "**",
21-
},
22-
],
23-
},
24-
25-
webpack(config) {
26-
// Grab the existing rule that handles SVG imports
27-
const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.(".svg"));
28-
29-
config.module.rules.push(
30-
// Reapply the existing rule, but only for svg imports ending in ?url
31-
{
32-
...fileLoaderRule,
33-
test: /\.svg$/i,
34-
resourceQuery: /url/, // *.svg?url
35-
},
36-
37-
// Convert all other *.svg imports to React components
38-
{
39-
test: /\.svg$/i,
40-
issuer: fileLoaderRule.issuer,
41-
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
42-
use: ["@svgr/webpack"],
43-
},
44-
);
45-
46-
// Modify the file loader rule to ignore *.svg, since we have it handled now.
47-
fileLoaderRule.exclude = /\.svg$/i;
48-
49-
return config;
50-
},
51-
};
52-
53-
export default nextConfig;
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
reactStrictMode: true,
4+
5+
// Increase static generation timeout to prevent build failures
6+
// when API calls take longer than default 60 seconds
7+
staticPageGenerationTimeout: 180, // 3 minutes (default is 60 seconds)
8+
9+
async redirects() {
10+
return [
11+
{
12+
source: "/((?!_next).*)js",
13+
destination: "/404",
14+
permanent: false,
15+
},
16+
];
17+
},
18+
19+
images: {
20+
// allow external source without limiting it to specific domains
21+
remotePatterns: [
22+
{
23+
protocol: "https",
24+
hostname: "**",
25+
},
26+
],
27+
},
28+
29+
webpack(config) {
30+
// Grab the existing rule that handles SVG imports
31+
const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.(".svg"));
32+
33+
config.module.rules.push(
34+
// Reapply the existing rule, but only for svg imports ending in ?url
35+
{
36+
...fileLoaderRule,
37+
test: /\.svg$/i,
38+
resourceQuery: /url/, // *.svg?url
39+
},
40+
41+
// Convert all other *.svg imports to React components
42+
{
43+
test: /\.svg$/i,
44+
issuer: fileLoaderRule.issuer,
45+
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
46+
use: ["@svgr/webpack"],
47+
},
48+
);
49+
50+
// Modify the file loader rule to ignore *.svg, since we have it handled now.
51+
fileLoaderRule.exclude = /\.svg$/i;
52+
53+
return config;
54+
},
55+
};
56+
57+
export default nextConfig;
Lines changed: 81 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
11
import { ReactElement } from "react";
22

33
import 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";
510
import { CampaignLayout } from "@/layout/campaign/components/layout";
611
import { 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
4636
export 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

Comments
 (0)