Skip to content

Commit 64fcdda

Browse files
committed
feat: Implement infinite scroll for hackathons page and enhance loading state management
- Added hook to manage loading more hackathons as the user scrolls. - Updated to utilize the new hook and log sentinel element references. - Refactored loading state display to improve user experience. - Adjusted to support pagination and logging for better debugging. - Modified API response handling to include pagination details for hackathons.
1 parent c584ac3 commit 64fcdda

7 files changed

Lines changed: 291 additions & 36 deletions

File tree

components/hackathons/HackathonsPage.tsx

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
'use client';
22

3-
import React from 'react';
3+
import React, { useState } from 'react';
44
import HackathonCard from '@/components/landing-page/hackathon/HackathonCard';
55
import HackathonsFiltersHeader from '@/components/hackathons/HackathonsFiltersHeader';
66
import LoadingSpinner from '@/components/LoadingSpinner';
77
import { useHackathonFilters } from '@/hooks/hackathon/use-hackathon-filters';
88
import { useHackathonsList } from '@/hooks/hackathon/use-hackathons-list';
99
import { useHackathonTransform } from '@/hooks/hackathon/use-hackathon-transform';
10+
import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
1011
import { BoundlessButton } from '../buttons';
11-
import { ArrowDownIcon, XIcon } from 'lucide-react';
12+
import { XIcon } from 'lucide-react';
1213
import LoadingScreen from '@/features/projects/components/CreateProjectModal/LoadingScreen';
1314
import EmptyState from '@/components/EmptyState';
1415

@@ -53,6 +54,22 @@ export default function HackathonsPage({
5354
});
5455
}, [hackathons, transformHackathonForCard]);
5556

57+
const [loadMoreSentinelEl, setLoadMoreSentinelRef] =
58+
useState<HTMLDivElement | null>(null);
59+
const setLoadMoreSentinelRefWithLog = React.useCallback(
60+
(el: HTMLDivElement | null) => {
61+
console.log('[HackathonsPage] Sentinel ref set', el ? 'element' : 'null');
62+
setLoadMoreSentinelRef(el);
63+
},
64+
[]
65+
);
66+
useInfiniteScroll(loadMoreSentinelEl, {
67+
onLoadMore: loadMore,
68+
hasMore,
69+
loading: loadingMore,
70+
rootMargin: '0px 0px 2000px 0px',
71+
});
72+
5673
return (
5774
<div className={className} id='explore-hackathons'>
5875
<HackathonsFiltersHeader
@@ -161,27 +178,16 @@ export default function HackathonsPage({
161178
{hackathonCards}
162179
</div>
163180

164-
{hasMore && (
165-
<div className='mt-8 flex items-center justify-center'>
166-
<BoundlessButton
167-
onClick={loadMore}
168-
variant='outline'
169-
disabled={loadingMore}
170-
icon={
171-
loadingMore ? undefined : (
172-
<ArrowDownIcon className='h-4 w-4' />
173-
)
174-
}
175-
className='flex items-center gap-2 rounded-lg px-8 py-3 font-medium text-white transition-colors disabled:cursor-not-allowed disabled:bg-gray-600'
176-
iconPosition='right'
177-
>
178-
{loadingMore && (
179-
<LoadingSpinner size='sm' variant='spinner' color='white' />
180-
)}
181-
{loadingMore
182-
? 'Loading more hackathons...'
183-
: 'Load More Hackathons'}
184-
</BoundlessButton>
181+
<div
182+
ref={setLoadMoreSentinelRefWithLog}
183+
className='h-px w-full'
184+
aria-hidden
185+
/>
186+
187+
{loadingMore && (
188+
<div className='mt-6 flex items-center justify-center gap-2 py-4 text-gray-400'>
189+
<LoadingSpinner size='sm' variant='spinner' color='white' />
190+
<span className='text-sm'>Loading more hackathons...</span>
185191
</div>
186192
)}
187193
</>

components/hackathons/hackathonStickyCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) {
6969
// Determine if registration is allowed
7070
const canRegister = useMemo(() => {
7171
const now = new Date();
72-
const policy = registrationDeadlinePolicy || 'before_submission_deadline';
72+
const policy = registrationDeadlinePolicy || 'BEFORE_SUBMISSION_DEADLINE';
7373

7474
switch (policy) {
7575
case 'BEFORE_START':
@@ -91,7 +91,7 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) {
9191
const now = new Date();
9292
const beforeStart = startDate && now < new Date(startDate);
9393

94-
switch (registrationDeadlinePolicy || 'before_submission_deadline') {
94+
switch (registrationDeadlinePolicy || 'BEFORE_SUBMISSION_DEADLINE') {
9595
case 'BEFORE_START':
9696
return 'Register';
9797
case 'BEFORE_SUBMISSION_DEADLINE':

content/blog/mdx-playground.mdx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
title: 'MDX Playground — Test All Blog Features'
3+
excerpt: 'A single post to verify frontmatter, prose styling, code blocks, tables, lists, blockquotes, and links. Use this to confirm the full MDX blog pipeline works end-to-end.'
4+
coverImage: 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=800&auto=format&fit=crop'
5+
publishedAt: '2025-02-26'
6+
author:
7+
name: 'Boundless Dev'
8+
image: 'https://i.pravatar.cc/150?img=33'
9+
categories: ['Platform', 'Technology', 'Education']
10+
tags: ['mdx', 'testing', 'playground', 'prose', 'markdown']
11+
readingTime: 8
12+
isFeatured: true
13+
---
14+
15+
# MDX Playground — Test All Blog Features
16+
17+
This post exists so you can **test the entire blog stack**: frontmatter, static params, metadata, related posts, and every content pattern your MDX pipeline supports.
18+
19+
## Frontmatter checklist
20+
21+
- **Title & excerpt** — Used in listing, detail header, and meta tags.
22+
- **coverImage** — Rendered above the body; also in Open Graph / Twitter cards.
23+
- **publishedAt** — Shown in the byline and in `generateMetadata`.
24+
- **author.name & author.image** — Avatar and name in the header.
25+
- **categories & tags** — Tags under the post; both used for related posts.
26+
- **readingTime** — Available in frontmatter (use in UI if needed).
27+
- **isFeatured** — Listed in `getAllBlogPosts()`; filter on the blog index if desired.
28+
29+
## Headings and text
30+
31+
Use **bold** and _italic_ and `inline code` to confirm prose styling. Headings from `h1` to `h6` should get correct sizes from `prose` (this section is `h2`, below are `h3`).
32+
33+
### Unordered list
34+
35+
- First item
36+
- Second with **emphasis**
37+
- Third with [internal link](/blog) and [external link](https://stellar.org)
38+
39+
### Ordered list
40+
41+
1. Step one
42+
2. Step two
43+
3. Step three
44+
45+
### Nested list
46+
47+
- Parent
48+
- Child A
49+
- Child B
50+
- Another parent
51+
52+
## Code blocks
53+
54+
Inline code: `const x = 1`.
55+
56+
Fenced block with language (syntax highlighting via prose):
57+
58+
```typescript
59+
function greet(name: string): string {
60+
return `Hello, ${name}!`;
61+
}
62+
```
63+
64+
Rust snippet (e.g. Soroban):
65+
66+
```rust
67+
pub fn release_milestone(env: Env, campaign_id: u64) -> Result<(), Error> {
68+
let campaign = env.storage().get::<Campaign>(&campaign_id)?;
69+
transfer_funds(&env, &campaign)
70+
}
71+
```
72+
73+
## Tables
74+
75+
| Feature | Status | Notes |
76+
| ------------- | ------- | -------------- |
77+
| Frontmatter | Working | YAML parsed |
78+
| Prose | Working | Tailwind |
79+
| Code blocks | Working | Fenced |
80+
| Related posts | Working | Same tags/cats |
81+
82+
## Blockquote
83+
84+
> This is a blockquote. It should render with the prose blockquote styles and any custom styling you added.
85+
86+
## Links
87+
88+
- [Blog index](/blog)
89+
- [Stellar](https://stellar.org)
90+
- [Trustless Work](https://trustlesswork.com)
91+
92+
## Summary
93+
94+
If this page loads at **`/blog/mdx-playground`**, and you see:
95+
96+
1. Correct title, author, date, and cover image
97+
2. All headings, lists, code, table, and blockquote styled correctly
98+
3. Tags at the bottom
99+
4. Related articles in the sidebar/footer (other posts sharing tags/categories)
100+
101+
then the MDX pipeline (content in `content/blog/`, `lib/mdx.ts`, and the blog pages) is working end to end.
102+
103+
To use **custom MDX components** (e.g. `<Badge>`, `<Mermaid>`), add a `components` map to `compileMDX` in `lib/mdx.ts` and re-export those components; then you can use them in any `.mdx` file like `<Badge>Label</Badge>`.

hooks/hackathon/use-hackathons-list.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ interface UseHackathonsListReturn {
3939
export function useHackathonsList(
4040
options: UseHackathonsListOptions = {}
4141
): UseHackathonsListReturn {
42-
const { initialPage = 1, pageSize = 9, initialFilters = {} } = options;
42+
const { initialPage = 1, pageSize = 10, initialFilters = {} } = options;
4343

4444
const [hackathons, setHackathons] = React.useState<Hackathon[]>([]);
4545
const [featuredHackathons, setFeaturedHackathons] = React.useState<
@@ -156,6 +156,10 @@ export function useHackathonsList(
156156
const fetchHackathons = React.useCallback(
157157
async (page: number, currentFilters: HackathonFilters, append = false) => {
158158
try {
159+
console.log('[useHackathonsList] fetchHackathons called', {
160+
page,
161+
append,
162+
});
159163
if (append) {
160164
setLoadingMore(true);
161165
} else {
@@ -182,6 +186,13 @@ export function useHackathonsList(
182186
await getPublicHackathonsList(apiFilters);
183187
let hackathonsList = response.hackathons || [];
184188

189+
console.log('[useHackathonsList] API response', {
190+
page,
191+
count: hackathonsList.length,
192+
total: response.total,
193+
hasMore: response.hasMore ?? false,
194+
});
195+
185196
// Apply client-side location filtering (API doesn't support it)
186197
hackathonsList = filterByLocation(
187198
hackathonsList,
@@ -212,6 +223,7 @@ export function useHackathonsList(
212223
setHackathons(hackathonsList);
213224
}
214225
} catch (err) {
226+
console.error('[useHackathonsList] fetchHackathons error', err);
215227
const errorMessage =
216228
err instanceof Error ? err.message : 'Failed to fetch hackathons';
217229
setError(errorMessage);
@@ -231,10 +243,20 @@ export function useHackathonsList(
231243
}, [filters, fetchHackathons]);
232244

233245
const loadMore = React.useCallback(() => {
246+
console.log('[useHackathonsList] loadMore called', {
247+
loadingMore,
248+
hasMore,
249+
currentPage,
250+
});
234251
if (!loadingMore && hasMore) {
235252
const nextPage = currentPage + 1;
253+
console.log('[useHackathonsList] Fetching next page', nextPage);
236254
setCurrentPage(nextPage);
237255
fetchHackathons(nextPage, filters, true);
256+
} else {
257+
console.log(
258+
'[useHackathonsList] loadMore skipped (loadingMore or !hasMore)'
259+
);
238260
}
239261
}, [loadingMore, hasMore, currentPage, filters, fetchHackathons]);
240262

hooks/use-infinite-scroll.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
interface UseInfiniteScrollOptions {
6+
onLoadMore: () => void;
7+
hasMore: boolean;
8+
loading: boolean;
9+
rootMargin?: string;
10+
}
11+
12+
/**
13+
* Triggers onLoadMore when the sentinel element enters the viewport (or within rootMargin of it).
14+
* Pass the sentinel DOM element (e.g. from a callback ref + state) so the observer attaches after mount.
15+
*/
16+
export function useInfiniteScroll(
17+
sentinel: HTMLElement | null,
18+
options: UseInfiniteScrollOptions
19+
): void {
20+
const {
21+
onLoadMore,
22+
hasMore,
23+
loading,
24+
rootMargin = '0px 0px 400px 0px',
25+
} = options;
26+
27+
useEffect(() => {
28+
if (!sentinel) {
29+
console.log(
30+
'[useInfiniteScroll] No sentinel element yet, skipping observer'
31+
);
32+
return;
33+
}
34+
35+
console.log('[useInfiniteScroll] Observer attached to sentinel', {
36+
hasMore,
37+
loading,
38+
rootMargin,
39+
});
40+
41+
const observer = new IntersectionObserver(
42+
entries => {
43+
const [entry] = entries;
44+
const isIntersecting = !!entry?.isIntersecting;
45+
console.log('[useInfiniteScroll] Observer callback', {
46+
isIntersecting,
47+
hasMore,
48+
loading,
49+
});
50+
51+
if (!entry?.isIntersecting) return;
52+
if (!hasMore) {
53+
console.log('[useInfiniteScroll] Skipped: hasMore is false');
54+
return;
55+
}
56+
if (loading) {
57+
console.log('[useInfiniteScroll] Skipped: already loading');
58+
return;
59+
}
60+
console.log('[useInfiniteScroll] Calling onLoadMore()');
61+
onLoadMore();
62+
},
63+
{
64+
root: null,
65+
rootMargin,
66+
threshold: 0,
67+
}
68+
);
69+
70+
observer.observe(sentinel);
71+
return () => {
72+
console.log('[useInfiniteScroll] Observer disconnected');
73+
observer.disconnect();
74+
};
75+
}, [sentinel, onLoadMore, hasMore, loading, rootMargin]);
76+
}

lib/api/hackathons.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,20 @@ export interface PublicHackathonsListResponse extends ApiResponse<PublicHackatho
11381138
success: true;
11391139
}
11401140

1141+
/** Raw shape from the public hackathons list API (uses pagination object) */
1142+
interface PublicHackathonsListRawData {
1143+
hackathons?: Hackathon[];
1144+
pagination?: {
1145+
page: number;
1146+
limit: number;
1147+
total: number;
1148+
totalPages: number;
1149+
hasNext: boolean;
1150+
hasPrev: boolean;
1151+
};
1152+
filters?: Record<string, unknown>;
1153+
}
1154+
11411155
export interface PublicHackathonsListFilters {
11421156
page?: number;
11431157
limit?: number;
@@ -1985,17 +1999,27 @@ export const getPublicHackathonsList = async (
19851999
const queryString = params.toString();
19862000
const url = `/hackathons${queryString ? `?${queryString}` : ''}`;
19872001

1988-
const res = await api.get<PublicHackathonsListResponse>(url);
2002+
const res = await api.get<ApiResponse<PublicHackathonsListRawData>>(url);
19892003

1990-
return (
1991-
res.data.data || {
2004+
const raw = res.data.data;
2005+
if (!raw) {
2006+
return {
19922007
hackathons: [],
19932008
hasMore: false,
19942009
total: 0,
19952010
currentPage: 1,
19962011
totalPages: 0,
1997-
}
1998-
);
2012+
};
2013+
}
2014+
2015+
const pagination = raw.pagination;
2016+
return {
2017+
hackathons: raw.hackathons || [],
2018+
hasMore: pagination?.hasNext ?? false,
2019+
total: pagination?.total ?? 0,
2020+
currentPage: pagination?.page ?? 1,
2021+
totalPages: pagination?.totalPages ?? 0,
2022+
};
19992023
};
20002024

20012025
// Error handling utilities

0 commit comments

Comments
 (0)