Skip to content

Commit a00a69a

Browse files
authored
Merge pull request #529 from PotLock/staging
Staging to prod
2 parents 89b9ef7 + a03cb0e commit a00a69a

File tree

9 files changed

+172
-262
lines changed

9 files changed

+172
-262
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useEffect, useState } from "react";
2+
3+
import { useRouter } from "next/router";
4+
5+
import { cn } from "../utils";
6+
7+
/**
8+
* Full-page loading overlay that shows during page navigations.
9+
* Only shows when navigating to a different page (not on query param changes).
10+
*/
11+
export const PageTransitionLoader = () => {
12+
const router = useRouter();
13+
const [isLoading, setIsLoading] = useState(false);
14+
15+
useEffect(() => {
16+
const handleStart = (url: string) => {
17+
const currentPath = router.asPath.split("?")[0];
18+
const targetPath = url.split("?")[0];
19+
20+
if (currentPath === targetPath) {
21+
return;
22+
}
23+
24+
const isCampaignPageTarget = targetPath.startsWith("/campaign/");
25+
26+
if (isCampaignPageTarget) {
27+
setIsLoading(true);
28+
}
29+
};
30+
31+
const handleComplete = () => {
32+
setIsLoading(false);
33+
};
34+
35+
const handleError = () => {
36+
setIsLoading(false);
37+
};
38+
39+
router.events.on("routeChangeStart", handleStart);
40+
router.events.on("routeChangeComplete", handleComplete);
41+
router.events.on("routeChangeError", handleError);
42+
43+
return () => {
44+
router.events.off("routeChangeStart", handleStart);
45+
router.events.off("routeChangeComplete", handleComplete);
46+
router.events.off("routeChangeError", handleError);
47+
};
48+
}, [router]);
49+
50+
if (!isLoading) return null;
51+
52+
return (
53+
<div
54+
className={cn(
55+
"fixed inset-0 z-[9999] flex items-center justify-center",
56+
"bg-white/80 backdrop-blur-sm",
57+
"transition-opacity duration-200",
58+
)}
59+
aria-label="Loading page"
60+
>
61+
<div className="flex flex-col items-center gap-4">
62+
{/* Spinner */}
63+
<div className="relative h-12 w-12">
64+
<div className={cn("absolute inset-0 rounded-full", "border-4 border-gray-200")} />
65+
<div
66+
className={cn(
67+
"absolute inset-0 rounded-full",
68+
"border-4 border-transparent border-t-[#dd3345]",
69+
"animate-spin",
70+
)}
71+
/>
72+
</div>
73+
<p className="text-sm font-medium text-gray-600">Loading...</p>
74+
</div>
75+
</div>
76+
);
77+
};

src/common/ui/layout/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export * from "./molecules/sort-select";
6666
export * from "./molecules/spinner-overlay";
6767
export * from "./molecules/toggle-group";
6868
export * from "./molecules/virtual-scroll";
69+
export * from "./PageTransitionLoader";
6970

7071
/**
7172
* Organisms

src/entities/campaign/components/CampaignCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const CampaignCard = ({ data }: { data: Campaign }) => {
2626
"transition-all duration-500 hover:shadow-[0_6px_10px_rgba(0,0,0,0.2)]",
2727
)}
2828
>
29-
<Link href={`/campaign/${data.on_chain_id}/leaderboard`} passHref prefetch>
29+
<Link href={`/campaign/${data.on_chain_id}`} passHref>
3030
<div className="relative h-[212px] w-full">
3131
<LazyLoadImage
3232
src={data?.cover_image_url || "/assets/images/list-gradient-3.png"}
Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,27 @@
1-
import { useCallback, useState } from "react";
1+
import { useCallback, useMemo, useState } from "react";
22

3-
import Link from "next/link";
43
import { useRouter } from "next/router";
54

65
import { PageWithBanner } from "@/common/ui/layout/components";
76
import { TabOption } from "@/common/ui/layout/types";
87
import { cn } from "@/common/ui/layout/utils";
9-
import { CampaignBanner } from "@/entities/campaign";
8+
import { CampaignBanner, CampaignDonorsTable, CampaignSettings } from "@/entities/campaign";
109

11-
const CAMPAIGN_TAB_ROUTES: TabOption[] = [
10+
const CAMPAIGN_TABS: { label: string; id: string }[] = [
1211
{
1312
label: "Donation History",
1413
id: "leaderboard",
15-
href: "/leaderboard",
1614
},
17-
{ label: "Settings", id: "settings", href: "/settings" },
15+
{ label: "Settings", id: "settings" },
1816
];
1917

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

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-
24+
const Tabs = ({ options, selectedTab, onSelect }: TabsProps) => {
3525
return (
3626
<div className="mb-8 flex w-full flex-row flex-wrap gap-2">
3727
<div className="w-full px-2 md:px-8">
@@ -42,35 +32,13 @@ const Tabs = ({ options, selectedTab, onSelect, asLink }: Props) => {
4232
)}
4333
>
4434
{options.map((option) => {
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-
}
35+
const selected = option.id === selectedTab;
6436

6537
return (
6638
<button
6739
key={option.id}
6840
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]"}`}
69-
onClick={() => {
70-
if (onSelect) {
71-
onSelect(option.id);
72-
}
73-
}}
41+
onClick={() => onSelect(option.id)}
7442
>
7543
{option.label}
7644
</button>
@@ -88,31 +56,63 @@ type ReactLayoutProps = {
8856

8957
export const CampaignLayout: React.FC<ReactLayoutProps> = ({ children }) => {
9058
const router = useRouter();
91-
const { campaignId } = router.query as { campaignId: string };
92-
const tabs = CAMPAIGN_TAB_ROUTES;
59+
const { campaignId, tab } = router.query as { campaignId: string; tab?: string };
9360

94-
const [selectedTab, setSelectedTab] = useState(
95-
tabs.find((tab) => router.pathname.includes(tab.href)) || tabs[0],
96-
);
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;
9775

98-
const handleSelectedTab = useCallback(
99-
(tabId: string) => setSelectedTab(tabs.find((tabRoute) => tabRoute.id === tabId)!),
100-
[tabs],
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],
10188
);
10289

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+
}
105+
103106
return (
104107
<PageWithBanner>
105108
<div className="md:p-8">
106-
<CampaignBanner campaignId={parseInt(campaignId)} />
109+
<CampaignBanner campaignId={numericCampaignId} />
107110
</div>
108111

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>
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>
116114
</PageWithBanner>
117115
);
118116
};
117+
118+
export { CAMPAIGN_TABS };

src/middleware.ts

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

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

55
export async function middleware(request: NextRequest) {
66
const { pathname } = request.nextUrl;
@@ -33,18 +33,6 @@ 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-
}
4836
}
4937

5038
return NextResponse.next();

src/pages/_app.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Head from "next/head";
1717
import { Provider as ReduxProvider } from "react-redux";
1818

1919
import { APP_METADATA } from "@/common/constants";
20-
import { TooltipProvider } from "@/common/ui/layout/components";
20+
import { PageTransitionLoader, TooltipProvider } from "@/common/ui/layout/components";
2121
import { Toaster } from "@/common/ui/layout/components/molecules/toaster";
2222
import { cn } from "@/common/ui/layout/utils";
2323
import { WalletUserSessionProvider } from "@/common/wallet";
@@ -53,6 +53,7 @@ export default function RootLayout({ Component, pageProps }: AppPropsWithLayout)
5353
<ReduxProvider {...{ store }}>
5454
<NiceModalProvider>
5555
<TooltipProvider>
56+
<PageTransitionLoader />
5657
<div
5758
className={cn(
5859
"font-lora flex h-full flex-col items-center antialiased",

src/pages/campaign/[campaignId]/index.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { ReactElement } from "react";
22

33
import type { GetServerSideProps } from "next";
44

5-
import { CampaignBanner, CampaignDonorsTable } from "@/entities/campaign";
65
import { CampaignLayout } from "@/layout/campaign/components/layout";
76
import { RootLayout } from "@/layout/components/root-layout";
87

@@ -14,10 +13,12 @@ type PageProps = {
1413
};
1514

1615
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
1718
return (
1819
<RootLayout title={props.seoTitle} description={props.seoDescription} image={props.seoImage}>
19-
<CampaignBanner campaignId={props.campaignId} />
20-
<CampaignDonorsTable campaignId={props.campaignId} />
20+
{/* Content rendered by CampaignLayout */}
21+
<></>
2122
</RootLayout>
2223
);
2324
}
@@ -69,7 +70,6 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async ({ params
6970
}
7071

7172
if (!response.ok) {
72-
// Return page with default SEO on API error
7373
return {
7474
props: {
7575
campaignId: numericCampaignId,
@@ -93,7 +93,6 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async ({ params
9393
} catch (error) {
9494
console.error(`Error fetching campaign ${campaignId}:`, error);
9595

96-
// Return page with default SEO on error (don't crash)
9796
return {
9897
props: {
9998
campaignId: numericCampaignId,

0 commit comments

Comments
 (0)