Skip to content

Commit c01c29b

Browse files
authored
Merge pull request #283 from WeGo-Together/somang-feat/group-details
[Feat] 참가자 추방 기능 적용
2 parents 64cfc93 + 2461123 commit c01c29b

File tree

11 files changed

+150
-52
lines changed

11 files changed

+150
-52
lines changed

src/api/service/group-service/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { apiV2 } from '@/api/core';
22
import {
3+
AttendGroupPayload,
34
CreateGroupPayload,
45
CreateGroupResponse,
56
GetGroupDetailsResponse,
@@ -70,7 +71,11 @@ export const groupServiceRemote = () => ({
7071
return apiV2.get<GetGroupDetailsResponse>(`/groups/${params.groupId}`);
7172
},
7273

73-
attendGroup: (params: GroupIdParams) => {
74+
attendGroup: (params: GroupIdParams, payload?: AttendGroupPayload) => {
75+
// 승인제 모임 신청 시 message 포함해서 API 요청
76+
if (payload?.message) {
77+
return apiV2.post<GetGroupDetailsResponse>(`/groups/${params.groupId}/attend`, payload);
78+
}
7479
return apiV2.post<GetGroupDetailsResponse>(`/groups/${params.groupId}/attend`);
7580
},
7681

src/app/group/[groupId]/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ const GroupDetailPage = ({ params }: Props) => {
2828
<div>
2929
<GroupBannerImages images={images} />
3030
<GroupDescriptions descriptions={data} />
31-
<GroupMembers members={joinedMembers} />
31+
<GroupMembers
32+
groupId={groupId}
33+
isHost={myMembership?.role === 'HOST'}
34+
members={joinedMembers}
35+
/>
3236
<GroupButtons
3337
conditions={{
3438
isHost: myMembership?.role === 'HOST',
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { AnyFieldApi } from '@tanstack/react-form';
2+
3+
import { Label } from '@/components/ui';
4+
5+
interface Props {
6+
field: AnyFieldApi;
7+
}
8+
9+
export const GroupPolicyField = ({ field }: Props) => {
10+
return (
11+
<div className='mt-6 space-y-4'>
12+
<Label htmlFor='create-group-policy'>참여 방식</Label>
13+
{POLICY_OPTIONS.map(({ type, name, description }) => {
14+
return (
15+
<div key={name} className='flex items-start gap-2'>
16+
<input
17+
id={name}
18+
className='size-6'
19+
checked={field.state.value === type}
20+
name='create-group-policy'
21+
type='radio'
22+
value={type}
23+
onChange={(e) => field.handleChange(e.target.value)}
24+
/>
25+
<Label htmlFor={name}>
26+
<p>{name}</p>
27+
<p className='text-gray-500'>{description}</p>
28+
</Label>
29+
</div>
30+
);
31+
})}
32+
</div>
33+
);
34+
};
35+
36+
const POLICY_OPTIONS = [
37+
{ type: 'FREE', name: '즉시 참여', description: '누구나 바로 모임에 참여할 수 있어요.' },
38+
{
39+
type: 'APPROVAL_REQUIRED',
40+
name: '승인 후 참여',
41+
description: '방장이 승인한 사람만 참여할 수 있어요.',
42+
},
43+
];

src/components/pages/group/group-members/index.tsx

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,30 @@ import { useState } from 'react';
77
import clsx from 'clsx';
88

99
import { Icon } from '@/components/icon';
10+
import { GroupModal } from '@/components/pages/group/group-modal';
1011
import { AnimateDynamicHeight } from '@/components/shared';
1112
import { Button, ImageWithFallback } from '@/components/ui';
12-
import { GetGroupDetailsResponse } from '@/types/service/group';
13+
import { useModal } from '@/components/ui';
14+
import { GetGroupDetailsResponse, KickGroupMemberParams } from '@/types/service/group';
1315

1416
interface Props {
1517
members: GetGroupDetailsResponse['joinedMembers'];
18+
isHost: boolean;
19+
groupId: string;
1620
}
1721

18-
export const GroupMembers = ({ members }: Props) => {
22+
export const GroupMembers = ({ members, isHost, groupId }: Props) => {
1923
const [expand, setExpand] = useState(false);
20-
const [coverMember, setCoverMember] = useState(2 < Math.ceil(members.length / 3));
21-
2224
const hasMoreMember = 2 < Math.ceil(members.length / 3);
2325

26+
const { open } = useModal();
27+
2428
const onExpandClick = () => {
2529
setExpand((prev) => !prev);
26-
setCoverMember((prev) => !prev);
30+
};
31+
32+
const onKickMemberClick = (targetUserId: KickGroupMemberParams['targetUserId']) => {
33+
open(<GroupModal groupId={groupId} targetUserId={targetUserId} type='kick' />);
2734
};
2835

2936
return (
@@ -35,41 +42,49 @@ export const GroupMembers = ({ members }: Props) => {
3542
{members.map(({ nickName, profileImage, userId }, idx) => (
3643
<li
3744
key={nickName}
38-
className={clsx(
39-
'relative',
40-
hasMoreMember && !expand ? '[&:nth-child(n+7)]:hidden' : 'block',
41-
)}
45+
className={hasMoreMember && !expand ? '[&:nth-child(n+6)]:hidden' : 'block'}
4246
>
4347
<div className='flex-col-center gap-1.5'>
44-
<Link href={`/profile/${userId}`}>
45-
<ImageWithFallback
46-
width={64}
47-
className='object-fit h-16 w-16 rounded-full'
48-
alt='프로필 사진'
49-
draggable={false}
50-
height={64}
51-
src={profileImage ?? ''}
52-
/>
53-
</Link>
48+
<div className='relative'>
49+
<Link href={`/profile/${userId}`}>
50+
<ImageWithFallback
51+
width={64}
52+
className='object-fit h-16 w-16 rounded-full'
53+
alt='프로필 사진'
54+
draggable={false}
55+
height={64}
56+
src={profileImage ?? ''}
57+
/>
58+
</Link>
59+
{isHost && idx !== 0 && (
60+
<button
61+
className='absolute top-0 right-0'
62+
type='button'
63+
onClick={() => onKickMemberClick(userId.toString())}
64+
>
65+
<Icon id='kick' className='h-4 w-4' />
66+
</button>
67+
)}
68+
</div>
69+
5470
<p
5571
className={clsx(
5672
'text-text-xs-medium line-clamp-1 w-full text-center break-all text-gray-800',
57-
coverMember && idx === 5 ? 'hidden' : 'block',
73+
hasMoreMember && !expand && idx === 5 ? 'hidden' : 'block',
5874
)}
5975
>
6076
{nickName}
6177
</p>
6278
</div>
63-
64-
{coverMember && idx === 5 && (
65-
<div className='absolute inset-0'>
66-
<span className='flex-center text-text-md-semibold mx-auto h-[65px] w-[65px] rounded-full bg-gray-200 text-gray-600'>
67-
{members.length - 5}+
68-
</span>
69-
</div>
70-
)}
7179
</li>
7280
))}
81+
{hasMoreMember && !expand && (
82+
<li className='mx-auto'>
83+
<div className='flex-center h-16 w-16 rounded-full bg-gray-200'>
84+
<span className='text-text-md-semibold text-gray-600'>{members.length - 5}+</span>
85+
</div>
86+
</li>
87+
)}
7388
</ul>
7489
</AnimateDynamicHeight>
7590

src/components/pages/group/group-modal/index.tsx

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

3-
import { useRouter } from 'next/navigation';
4-
53
import { Button } from '@/components/ui';
64
import { ModalContent, ModalDescription, ModalTitle, useModal } from '@/components/ui/modal';
75
import { useAttendGroup } from '@/hooks/use-group/use-group-attend';
86
import { useDeleteGroup } from '@/hooks/use-group/use-group-delete';
7+
import { useKickGroupMember } from '@/hooks/use-group/use-group-kick';
98
import { useLeaveGroup } from '@/hooks/use-group/use-group-leave';
109

1110
interface Props {
12-
type: 'attend' | 'leave' | 'delete';
11+
type: 'attend' | 'leave' | 'delete' | 'kick';
1312
groupId: string;
13+
targetUserId?: string;
1414
}
1515

16-
export const GroupModal = ({ type, groupId }: Props) => {
17-
const { replace } = useRouter();
16+
export const GroupModal = ({ type, groupId, targetUserId = '' }: Props) => {
1817
const { close } = useModal();
19-
const { mutate: attendMutate, isPending: isAttending } = useAttendGroup({ groupId }, close);
20-
const { mutate: leaveMutate, isPending: isCanceling } = useLeaveGroup({ groupId }, close);
21-
const { mutate: deleteMutate, isPending: isDeleting } = useDeleteGroup({ groupId }, () => {
22-
close();
23-
replace('/');
18+
const { mutateAsync: attendMutate, isPending: isAttending } = useAttendGroup({ groupId });
19+
const { mutateAsync: leaveMutate, isPending: isCanceling } = useLeaveGroup({ groupId });
20+
const { mutateAsync: deleteMutate, isPending: isDeleting } = useDeleteGroup({ groupId });
21+
const { mutateAsync: kickMutate, isPending: isKicking } = useKickGroupMember({
22+
groupId,
23+
targetUserId,
2424
});
2525

26-
const isPending = isAttending || isCanceling || isDeleting;
26+
const isPending = isAttending || isCanceling || isDeleting || isKicking;
2727

2828
const { title, description, confirm } = MODAL_MESSAGE[type];
2929

30-
const handleConfirmClick = () => {
31-
if (type === 'attend') attendMutate();
32-
else if (type === 'leave') leaveMutate();
33-
else if (type === 'delete') deleteMutate();
30+
const handleConfirmClick = async () => {
31+
if (type === 'attend') await attendMutate();
32+
else if (type === 'leave') await leaveMutate();
33+
else if (type === 'delete') await deleteMutate();
34+
else if (type === 'kick') await kickMutate();
35+
36+
close();
3437
};
3538

3639
return (
@@ -75,4 +78,9 @@ const MODAL_MESSAGE = {
7578
description: '취소 후에는 다시 복구할 수 없어요.',
7679
confirm: '취소하기',
7780
},
81+
kick: {
82+
title: `을 내보내시겠어요?`,
83+
description: '이 작업은 취소할 수 없습니다.',
84+
confirm: '내보내기',
85+
},
7886
};

src/hooks/use-group/use-group-attend/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ import { API } from '@/api';
44
import { groupKeys } from '@/lib/query-key/query-key-group';
55
import { GroupIdParams } from '@/types/service/group';
66

7-
export const useAttendGroup = (params: GroupIdParams, callback: () => void) => {
7+
export const useAttendGroup = (params: GroupIdParams) => {
88
const queryClient = useQueryClient();
99

1010
const query = useMutation({
1111
mutationFn: () => API.groupService.attendGroup(params),
1212
onSuccess: async () => {
1313
await queryClient.invalidateQueries({ queryKey: groupKeys.detail(params.groupId) });
14-
callback();
1514
console.log('모임 참여 성공.');
1615
},
1716
onError: () => {

src/hooks/use-group/use-group-delete/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ import { useMutation } from '@tanstack/react-query';
33
import { API } from '@/api';
44
import { GroupIdParams } from '@/types/service/group';
55

6-
export const useDeleteGroup = (params: GroupIdParams, callback: () => void) => {
6+
export const useDeleteGroup = (params: GroupIdParams) => {
77
const query = useMutation({
88
mutationFn: () => API.groupService.deleteGroup(params),
99
onSuccess: async () => {
1010
console.log('모임 삭제 성공.');
11-
callback();
1211
},
1312
onError: () => {
1413
console.log('모임 삭제 실패.');
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query';
2+
3+
import { API } from '@/api';
4+
import { groupKeys } from '@/lib/query-key/query-key-group';
5+
import { KickGroupMemberParams } from '@/types/service/group';
6+
7+
export const useKickGroupMember = (params: KickGroupMemberParams) => {
8+
const queryClient = useQueryClient();
9+
10+
const query = useMutation({
11+
mutationFn: () => API.groupService.kickGroupMember(params),
12+
onSuccess: async () => {
13+
await queryClient.invalidateQueries({ queryKey: groupKeys.detail(params.groupId) });
14+
console.log('강퇴 성공.');
15+
},
16+
onError: () => {
17+
console.log('강퇴 실패.');
18+
},
19+
});
20+
return query;
21+
};

src/hooks/use-group/use-group-leave/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ import { API } from '@/api';
44
import { groupKeys } from '@/lib/query-key/query-key-group';
55
import { GroupIdParams } from '@/types/service/group';
66

7-
export const useLeaveGroup = (params: GroupIdParams, callback: () => void) => {
7+
export const useLeaveGroup = (params: GroupIdParams) => {
88
const queryClient = useQueryClient();
99

1010
const query = useMutation({
1111
mutationFn: () => API.groupService.leaveGroup(params),
1212
onSuccess: async () => {
1313
await queryClient.invalidateQueries({ queryKey: groupKeys.detail(params.groupId) });
14-
callback();
1514
console.log('모임 탈퇴 성공.');
1615
},
1716
onError: () => {

src/lib/formatDateTime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export const formatTimeAgo = (isoString: string) => {
3333

3434
// 모임 시작 시간을 Card 컴포넌트용 형식으로 변환 (예: "25. 12. 25 - 19:00")
3535
export const formatDateTime = (startTime: string, customFormat?: string): string => {
36-
const start = new Date(startTime);
36+
let start = new Date(startTime);
37+
if (!startTime.endsWith('Z')) start = new Date(startTime + 'Z');
3738

3839
const fullYear = start.getFullYear().toString();
3940
const shortYear = fullYear.slice(-2);

0 commit comments

Comments
 (0)