Skip to content

Commit cbcf37d

Browse files
authored
✨ Implement pagination for Guest page (#48)
### πŸ“ μž‘μ—… λ‚΄μš© - μ°Έμ—¬μž νŽ˜μ΄μ§€ λ¬΄ν•œ μŠ€ν¬λ‘€μ„ κ΅¬ν˜„ν–ˆμŠ΅λ‹ˆλ‹€. - μš°μ„  λ””μžμΈμ€ μ‹ κ²½μ“°μ§€ μ•Šκ³ , νƒ­ κΈ°λŠ₯κ³Ό λ¬΄ν•œ 슀크둀만 μΆ”κ°€ν–ˆμŠ΅λ‹ˆλ‹€. ### πŸ“Έ μŠ€ν¬λ¦°μƒ· (선택) ### πŸš€ 리뷰 μš”κ΅¬μ‚¬ν•­ (선택)
1 parent e7a681a commit cbcf37d

File tree

6 files changed

+233
-69
lines changed

6 files changed

+233
-69
lines changed

β€Žpackage.jsonβ€Ž

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@radix-ui/react-slot": "^1.2.4",
2525
"@radix-ui/react-switch": "^1.2.6",
2626
"@tailwindcss/vite": "^4.1.18",
27+
"@tanstack/react-query": "^5.90.20",
2728
"axios": "^1.13.2",
2829
"class-variance-authority": "^0.7.1",
2930
"clsx": "^2.1.1",
@@ -33,13 +34,15 @@
3334
"react-aria-components": "^1.14.0",
3435
"react-day-picker": "^9.13.0",
3536
"react-dom": "^19.1.0",
37+
"react-intersection-observer": "^10.0.2",
3638
"react-router": "^7.11.0",
3739
"sonner": "^2.0.7",
3840
"tailwind-merge": "^3.4.0",
3941
"zustand": "^5.0.9"
4042
},
4143
"devDependencies": {
4244
"@biomejs/biome": "1.9.4",
45+
"@tanstack/react-query-devtools": "^5.91.3",
4346
"@types/node": "^22.15.29",
4447
"@types/react": "^19.1.2",
4548
"@types/react-dom": "^19.1.2",
@@ -52,8 +55,6 @@
5255
"vite": "^6.3.5"
5356
},
5457
"msw": {
55-
"workerDirectory": [
56-
"public"
57-
]
58+
"workerDirectory": ["public"]
5859
}
5960
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { getGuests } from '@/api/events/registrations';
2+
import type { GuestsParams, GuestsResponse } from '@/types/events';
3+
import type { InfiniteData } from '@tanstack/react-query';
4+
import { useInfiniteQuery } from '@tanstack/react-query';
5+
6+
interface UseInfiniteGuestsProps {
7+
eventId: string;
8+
filters: Omit<GuestsParams, 'cursor'>;
9+
}
10+
11+
export default function useInfiniteGuests({
12+
eventId,
13+
filters,
14+
}: UseInfiniteGuestsProps) {
15+
return useInfiniteQuery<
16+
GuestsResponse,
17+
Error,
18+
InfiniteData<GuestsResponse>,
19+
(string | object)[],
20+
number | undefined
21+
>({
22+
// ν•„ν„° 쑰건이 λ°”λ€” λ•Œλ§ˆλ‹€ μƒˆλ‘œμš΄ 쿼리둜 μΈμ‹ν•˜λ„λ‘ μ„€μ •
23+
queryKey: ['guests', eventId, filters],
24+
25+
queryFn: async ({ pageParam }) => {
26+
const response = await getGuests(eventId, {
27+
...filters,
28+
cursor: pageParam,
29+
});
30+
return response.data;
31+
},
32+
33+
// 첫 νŽ˜μ΄μ§€ 호좜 μ‹œ cursorλŠ” μ—†μœΌλ―€λ‘œ undefined, νƒ€μž…μ€ number둜 λͺ…μ‹œ
34+
initialPageParam: undefined as number | undefined,
35+
36+
getNextPageParam: (lastPage) => {
37+
return lastPage.hasNext ? lastPage.nextCursor : undefined;
38+
},
39+
});
40+
}

β€Žsrc/main.tsxβ€Ž

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2+
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
13
import { StrictMode } from 'react';
24
import { createRoot } from 'react-dom/client';
35
import App from './App';
@@ -22,10 +24,23 @@ async function enableMocking() {
2224
});
2325
}
2426

27+
const queryClient = new QueryClient({
28+
defaultOptions: {
29+
queries: {
30+
// 데이터가 5λΆ„ λ™μ•ˆμ€ μ‹ μ„ ν•˜λ‹€κ³  κ°„μ£Όν•˜μ—¬ λΆˆν•„μš”ν•œ μž¬μš”μ²­ λ°©μ§€
31+
staleTime: 5 * 60 * 1000,
32+
},
33+
},
34+
});
35+
2536
enableMocking().then(() => {
2637
createRoot(rootElement).render(
2738
<StrictMode>
28-
<App />
39+
<QueryClientProvider client={queryClient}>
40+
<App />
41+
{/* 개발 도ꡬ μΆ”κ°€ (개발 ν™˜κ²½μ—μ„œλ§Œ λ³΄μž„) */}
42+
<ReactQueryDevtools initialIsOpen={false} />
43+
</QueryClientProvider>
2944
</StrictMode>
3045
);
3146
});

β€Žsrc/routes/Guests.tsxβ€Ž

Lines changed: 117 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useEffect } from 'react';
1+
import { useEffect, useState } from 'react';
2+
import { useInView } from 'react-intersection-observer';
23
import { useNavigate, useParams } from 'react-router';
34
import { toast } from 'sonner';
4-
import useEventDetail from '../hooks/useEventDetail';
5+
import useInfiniteGuests from '../hooks/useInfiniteGuests';
56

67
// shadcn UI μ»΄ν¬λ„ŒνŠΈ
78
import {
@@ -17,26 +18,42 @@ import {
1718
} from '@/components/ui/alert-dialog';
1819
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
1920
import { Button } from '@/components/ui/button';
20-
import { ChevronLeftIcon } from 'lucide-react';
21+
import type { GuestStatus } from '@/types/schemas';
22+
import { ChevronLeftIcon, Loader2 } from 'lucide-react';
2123

2224
export default function Guests() {
2325
const { id } = useParams<{ id: string }>();
2426
const navigate = useNavigate();
25-
const { loading, guests, handleFetchRegistrations } = useEventDetail();
27+
const [activeTab, setActiveTab] = useState<GuestStatus>('CONFIRMED');
28+
29+
// 1. λ¬΄ν•œ 슀크둀 ν›… μ—°κ²°
30+
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
31+
useInfiniteGuests({
32+
eventId: id!,
33+
filters: {
34+
status: activeTab,
35+
orderBy: 'registeredAt',
36+
},
37+
});
38+
39+
const totalCount = data?.pages[0]?.totalCount ?? 0;
40+
41+
// 2. λ°”λ‹₯ 감지 ν›… (λ§ˆμ§€λ§‰ μš”μ†Œκ°€ 보이면 λ‹€μŒ νŽ˜μ΄μ§€ λ‘œλ“œ)
42+
const { ref, inView } = useInView();
2643

2744
useEffect(() => {
28-
if (id) {
29-
handleFetchRegistrations(id);
45+
if (inView && hasNextPage && !isFetchingNextPage) {
46+
fetchNextPage();
3047
}
31-
}, [id, handleFetchRegistrations]);
48+
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
3249

3350
const handleCancelGuest = (name: string | null) => {
34-
// μ‚­μ œ API ν•„μš”
51+
// TODO: patchRegistration API 호좜 둜직 μΆ”κ°€ ν•„μš”
3552
toast.success(`${name} λ‹˜μ˜ μ°Έμ—¬κ°€ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.`);
3653
};
3754

3855
// λ‘œλ”© μ€‘μ΄κ±°λ‚˜ 데이터가 아직 없을 λ•Œ (λ¦¬λ‹€μ΄λ ‰νŠΈ νŒλ‹¨ μ „) μŠ€ν”Όλ„ˆλ‚˜ 빈 ν™”λ©΄ ν‘œμ‹œ
39-
if (loading || !guests) {
56+
if (isLoading || !data) {
4057
return (
4158
<div className="min-h-screen flex items-center justify-center">
4259
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-black" />
@@ -58,68 +75,105 @@ export default function Guests() {
5875
<ChevronLeftIcon />
5976
</Button>
6077
<h1 className="text-2xl sm:text-3xl font-bold ml-4 text-black">
61-
μ°Έμ—¬μž λͺ…단({guests.length})
78+
μ°Έμ—¬μž λͺ…단({totalCount})
6279
</h1>
6380
</div>
6481
</div>
6582

66-
{/* 2. μ°Έμ—¬μž 리슀트 */}
83+
{/* 2. νƒ­ UI μ˜μ—­ */}
84+
<div className="flex border-b">
85+
{['전체', 'μ°Έμ—¬μž', 'λŒ€κΈ°μž'].map((label, idx) => {
86+
const tabValue = ['ALL', 'CONFIRMED', 'WAITING'][idx] as GuestStatus;
87+
return (
88+
<button
89+
key={tabValue}
90+
onClick={() => setActiveTab(tabValue)}
91+
className={`flex-1 py-4 font-bold ${activeTab === tabValue ? 'border-b-2 border-black text-black' : 'text-gray-400'}`}
92+
>
93+
{label}
94+
</button>
95+
);
96+
})}
97+
</div>
98+
99+
{/* 3. μ°Έμ—¬μž 리슀트 */}
67100
<div className="max-w-2xl min-w-[320px] mx-auto w-[90%] px-6 flex flex-col gap-8 mt-4">
68-
{guests.map((guest) => (
69-
<div
70-
key={guest.registrationId}
71-
className="flex items-center justify-between w-full"
72-
>
73-
<div className="flex items-center gap-4">
74-
<Avatar className="w-16 h-16 border-none shadow-sm">
75-
<AvatarImage src={guest.email || undefined} />
76-
<AvatarFallback className="bg-black text-white text-xs">
77-
{guest.name?.slice(0, 2)}
78-
</AvatarFallback>
79-
</Avatar>
80-
<div className="flex flex-col">
81-
<span className="text-xl font-bold text-black">
82-
{guest.name}
83-
</span>
84-
{guest.email ? (
85-
<span className="text-gray-400 text-lg">{guest.email}</span>
86-
) : null}
101+
{data?.pages.map((page) =>
102+
page.participants.map((guest) => (
103+
<div
104+
key={guest.registrationId}
105+
className="flex items-center justify-between w-full"
106+
>
107+
<div className="flex items-center gap-4">
108+
<Avatar className="w-16 h-16 border-none shadow-sm">
109+
<AvatarImage src={guest.profileImage || undefined} />
110+
<AvatarFallback className="bg-black text-white text-xs">
111+
{guest.name?.slice(0, 2)}
112+
</AvatarFallback>
113+
</Avatar>
114+
<div className="flex flex-col">
115+
<span className="text-xl font-bold text-black">
116+
{guest.name}
117+
</span>
118+
{guest.email ? (
119+
<span className="text-gray-400 text-lg">{guest.email}</span>
120+
) : null}
121+
{guest.status === 'WAITLISTED' && (
122+
<span className="text-amber-600 text-sm font-semibold">
123+
λŒ€κΈ° {guest.waitlistPosition}번
124+
</span>
125+
)}
126+
</div>
87127
</div>
88-
</div>
89128

90-
{/* κ°•μ œμ·¨μ†Œ λ²„νŠΌ */}
91-
<AlertDialog>
92-
<AlertDialogTrigger asChild>
93-
<Button
94-
variant="secondary"
95-
className="bg-[#333333] hover:bg-black text-white rounded-lg px-4 py-6 text-base font-bold"
96-
>
97-
κ°•μ œμ·¨μ†Œ
98-
</Button>
99-
</AlertDialogTrigger>
100-
<AlertDialogContent>
101-
<AlertDialogHeader>
102-
<AlertDialogTitle>
103-
<strong>{guest.name}</strong> λ‹˜μ˜ 신청을 μ·¨μ†Œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?
104-
</AlertDialogTitle>
105-
<AlertDialogDescription>
106-
μ·¨μ†Œ ν›„ 원볡이 μ–΄λ ΅μŠ΅λ‹ˆλ‹€. μ·¨μ†Œ 메일이 μ°Έμ—¬μžμ—κ²Œ
107-
μ „μ†‘λ©λ‹ˆλ‹€.
108-
</AlertDialogDescription>
109-
</AlertDialogHeader>
110-
<AlertDialogFooter>
111-
<AlertDialogCancel>μ‹ μ²­ μœ μ§€ν•˜κΈ°</AlertDialogCancel>
112-
<AlertDialogAction
113-
onClick={() => handleCancelGuest(guest.name)}
114-
className="bg-red-600 hover:bg-red-700"
129+
{/* κ°•μ œμ·¨μ†Œ λ²„νŠΌ */}
130+
<AlertDialog>
131+
<AlertDialogTrigger asChild>
132+
<Button
133+
variant="secondary"
134+
className="bg-[#333333] hover:bg-black text-white rounded-lg px-4 py-6 text-base font-bold"
115135
>
116-
μ·¨μ†Œν•˜κΈ°
117-
</AlertDialogAction>
118-
</AlertDialogFooter>
119-
</AlertDialogContent>
120-
</AlertDialog>
121-
</div>
122-
))}
136+
κ°•μ œμ·¨μ†Œ
137+
</Button>
138+
</AlertDialogTrigger>
139+
<AlertDialogContent>
140+
<AlertDialogHeader>
141+
<AlertDialogTitle>
142+
<strong>{guest.name}</strong> λ‹˜μ˜ 신청을
143+
μ·¨μ†Œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?
144+
</AlertDialogTitle>
145+
<AlertDialogDescription>
146+
μ·¨μ†Œ ν›„ 원볡이 μ–΄λ ΅μŠ΅λ‹ˆλ‹€. μ·¨μ†Œ 메일이 μ°Έμ—¬μžμ—κ²Œ
147+
μ „μ†‘λ©λ‹ˆλ‹€.
148+
</AlertDialogDescription>
149+
</AlertDialogHeader>
150+
<AlertDialogFooter>
151+
<AlertDialogCancel>μ‹ μ²­ μœ μ§€ν•˜κΈ°</AlertDialogCancel>
152+
<AlertDialogAction
153+
onClick={() => handleCancelGuest(guest.name)}
154+
className="bg-red-600 hover:bg-red-700"
155+
>
156+
μ·¨μ†Œν•˜κΈ°
157+
</AlertDialogAction>
158+
</AlertDialogFooter>
159+
</AlertDialogContent>
160+
</AlertDialog>
161+
</div>
162+
))
163+
)}
164+
165+
{/* 4. ν•˜λ‹¨ λ¬΄ν•œ 슀크둀 트리거 & λ‘œλ”© 인디케이터 */}
166+
<div ref={ref} className="py-10 flex justify-center">
167+
{isFetchingNextPage ? (
168+
<Loader2 className="animate-spin h-8 w-8 text-gray-400" />
169+
) : hasNextPage ? (
170+
<p className="text-gray-400 text-sm">
171+
λͺ©λ‘μ„ 더 뢈러였고 μžˆμŠ΅λ‹ˆλ‹€...
172+
</p>
173+
) : (
174+
<p className="text-gray-400 text-sm">λ§ˆμ§€λ§‰ μ°Έμ—¬μžμž…λ‹ˆλ‹€.</p>
175+
)}
176+
</div>
123177
</div>
124178
</div>
125179
);

β€Žsrc/types/events.tsβ€Ž

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,14 @@ export interface MyEvent {
9696
export interface GuestsParams {
9797
status?: GuestStatus;
9898
orderBy?: 'name' | 'registeredAt';
99-
cursor?: string;
99+
cursor?: number;
100100
}
101101

102102
export interface GuestsResponse {
103103
participants: Guest[];
104-
nextCursor: string;
104+
nextCursor: number;
105105
hasNext: boolean;
106+
totalCount: number;
106107
}
107108

108109
// ---------- POST /:id/registrations ----------

0 commit comments

Comments
Β (0)