Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/components/common/ToggleSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ interface ToggleSwitchProps {
label: string;
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
layout?: 'vertical' | 'horizontal';
className?: string;
}

function ToggleSwitch({ icon: Icon, label, enabled, onChange, layout = 'vertical', className }: ToggleSwitchProps) {
function ToggleSwitch({
icon: Icon,
label,
enabled,
onChange,
disabled = false,
layout = 'vertical',
className,
}: ToggleSwitchProps) {
const isHorizontal = layout === 'horizontal';

return (
Expand Down Expand Up @@ -38,9 +47,10 @@ function ToggleSwitch({ icon: Icon, label, enabled, onChange, layout = 'vertical
type="button"
aria-label={label}
aria-pressed={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
className={twMerge(
'relative touch-manipulation rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-indigo-300 focus-visible:ring-offset-2 focus-visible:outline-none',
'relative touch-manipulation rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-indigo-300 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60',
isHorizontal
? `h-7 w-12 border border-indigo-50 ${enabled ? 'bg-indigo-700' : 'bg-indigo-50'}`
: `h-5 w-9 ${enabled ? 'bg-primary' : 'bg-indigo-100'}`
Expand Down
8 changes: 5 additions & 3 deletions src/components/layout/Header/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ function ChatHeader() {
const { chatRoomId } = useParams();
const numericRoomId = Number(chatRoomId);

const { chatRoomList, clubMembers, toggleMute } = useChat(numericRoomId);
const { chatRoomList, clubMembers, toggleMute, isTogglingMute } = useChat(numericRoomId);

const chatRoom = chatRoomList.rooms.find((room) => room.roomId === numericRoomId);

Expand Down Expand Up @@ -55,10 +55,12 @@ function ChatHeader() {
<span className="text-sm font-medium">알림</span>

<button
onClick={() => toggleMute()}
type="button"
disabled={isTogglingMute}
onClick={() => void toggleMute()}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isMuted ? 'bg-gray-300' : 'bg-primary'
}`}
} disabled:cursor-not-allowed disabled:opacity-60`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
Expand Down
11 changes: 7 additions & 4 deletions src/pages/Chat/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ const formatTime = (dateString: string) => {

function ChatRoom() {
const { chatRoomId } = useParams();
const { sendMessage, chatMessages, fetchNextPage, hasNextPage, isFetchingNextPage, chatRoomList } = useChat(
Number(chatRoomId)
);
const { sendMessage, chatMessages, fetchNextPage, hasNextPage, isFetchingNextPage, chatRoomList, isSendingMessage } =
useChat(Number(chatRoomId));
const [value, setValue] = useState('');

useKeyboardHeight();
Expand Down Expand Up @@ -185,7 +184,11 @@ function ChatRoom() {
maxLength={1000}
/>

<button type="submit" className="bg-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-sm">
<button
type="submit"
disabled={isSendingMessage || !value.trim()}
className="bg-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-sm disabled:opacity-50"
>
<PaperPlaneIcon className="text-indigo-0" />
</button>
</form>
Expand Down
18 changes: 12 additions & 6 deletions src/pages/Chat/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ const useChat = (chatRoomId?: number) => {
refetchInterval: 5000,
});

const { mutateAsync: createChatRoom } = useMutation({
const createChatRoomMutation = useMutation({
mutationKey: ['createChatRoom'],
mutationFn: (userId: number) => postChatRooms(userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: chatQueryKeys.rooms() });
},
});
Comment on lines +20 to 26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/pages/Chat/hooks/useChat.ts

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 3867


🏁 Script executed:

rg -n "createChatRoom" --type=ts --type=tsx -A 5 -B 2

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 95


🏁 Script executed:

fd "useChat" --type=ts --type=tsx

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 237


🏁 Script executed:

rg "createChatRoom" -A 5 -B 2

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 1994


🏁 Script executed:

rg "useChat" -l

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 419


에러 처리 및 캐시 무효화 누락 처리 필요

createChatRoomMutation에 에러 핸들링이 없을 뿐만 아니라, 성공 시 캐시 무효화도 누락되어 있습니다. ClubIntro.tsx의 handleInquireClick에서도 try-catch가 없어 요청 실패 시 사용자 피드백이 전혀 없습니다.

다른 mutation들(sendMessage, toggleMute)과 일관되게 다음을 추가하세요:

  • onSuccess에서 chatRoomList 캐시 무효화
  • onError 핸들러 또는 컴포넌트에서 에러 처리
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/hooks/useChat.ts` around lines 20 - 23, The
createChatRoomMutation (mutationFn: postChatRooms) lacks onSuccess/onError
handlers and should mirror other mutations: add an onSuccess that invalidates
the chatRoomList cache (use your query client/invalidation util used elsewhere)
and add an onError to surface errors (e.g., show toast or propagate). Also
update ClubIntro.tsx's handleInquireClick to wrap the createChatRoom call in
try/catch and handle failures (display error feedback) or await the mutation's
promise and use its error handler so request failures don't silently fail.


const {
Expand Down Expand Up @@ -50,7 +53,7 @@ const useChat = (chatRoomId?: number) => {

const totalUnreadCount = chatRoomList.rooms.reduce((sum, room) => sum + room.unreadCount, 0);

const { mutate: sendMessage } = useMutation({
const sendMessageMutation = useMutation({
mutationKey: ['sendMessage', chatRoomId],
mutationFn: postChatMessage,

Expand All @@ -69,7 +72,7 @@ const useChat = (chatRoomId?: number) => {

const { data: clubMembersData } = useGetClubMembers(clubId);

const { mutateAsync: toggleMute } = useMutation({
const toggleMuteMutation = useMutation({
mutationKey: ['toggleMute', chatRoomId],
mutationFn: async () => {
if (!chatRoomId) {
Expand All @@ -87,15 +90,18 @@ const useChat = (chatRoomId?: number) => {

return {
chatRoomList,
createChatRoom,
createChatRoom: createChatRoomMutation.mutateAsync,
isCreatingChatRoom: createChatRoomMutation.isPending,
chatMessages: allMessages,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
totalUnreadCount,
sendMessage,
sendMessage: sendMessageMutation.mutate,
isSendingMessage: sendMessageMutation.isPending,
clubMembers: clubMembersData?.clubMembers ?? [],
toggleMute,
toggleMute: toggleMuteMutation.mutateAsync,
isTogglingMute: toggleMuteMutation.isPending,
};
};

Expand Down
18 changes: 6 additions & 12 deletions src/pages/Club/Application/clubFeePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,21 @@ function ClubFeePage() {
const { clubId } = useParams();
const navigate = useNavigate();
const { data: clubFee } = useGetClubFee(Number(clubId));
const { applyToClub } = useApplyToClub(Number(clubId));
const { applyToClub, isPending: isApplyingToClub } = useApplyToClub(Number(clubId));
const { answers, clubId: storedClubId } = useClubApplicationStore();

useEffect(() => {
if (storedClubId == null || storedClubId !== Number(clubId)) {
navigate(`/clubs/${clubId}/apply`, { replace: true });
}
}, [storedClubId, clubId, navigate]);
const { mutateAsync: uploadImage } = useUploadImage('CLUB');
const { mutateAsync: uploadImage, isPending: isUploadingImage } = useUploadImage('CLUB');

const fileInputRef = useRef<HTMLInputElement>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { value: isImageOpen, setTrue: openImage, setFalse: closeImage } = useBooleanState();
const isSubmitting = isApplyingToClub || isUploadingImage;

useEffect(() => {
return () => {
Expand All @@ -49,14 +49,8 @@ function ClubFeePage() {

const handleSubmit = async () => {
if (!imageFile) return;
setIsSubmitting(true);

try {
const { fileUrl } = await uploadImage(imageFile);
await applyToClub({ answers, feePaymentImageUrl: fileUrl });
} finally {
setIsSubmitting(false);
}
const { fileUrl } = await uploadImage(imageFile);
await applyToClub({ answers, feePaymentImageUrl: fileUrl });
};
Comment on lines 50 to 54
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

페이지의 제출 비즈니스 로직은 커스텀 훅으로 분리해 주세요.

Line 50-54의 업로드+지원 orchestration은 라우트 엔트리 페이지보다 훅으로 옮기는 쪽이 유지보수에 유리합니다.

As per coding guidelines "비즈니스 로직은 커스텀 훅으로 분리되어 있는지".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Club/Application/clubFeePage.tsx` around lines 50 - 54, Extract the
submission orchestration from the page into a custom hook (e.g., create
useClubApplication) so handleSubmit only calls the hook-provided submit handler;
specifically move the logic that checks imageFile, calls uploadImage(imageFile)
and then applyToClub({ answers, feePaymentImageUrl: fileUrl }) into the hook,
accept inputs (answers and imageFile or provide setters) and expose a submit
function, loading/error state and any setters needed; update the page to import
useClubApplication and replace the inline handleSubmit with the hook's submit
handler to keep business logic out of the route component.

⚠️ Potential issue | 🟠 Major

중복 제출 방지를 위해 handleSubmit 내부 가드를 추가해 주세요.

현재는 버튼 disabled에 의존하고 있어, 재진입 케이스에서 중복 호출 가능성을 완전히 막지 못합니다. Line 50에서 조기 반환 가드를 두는 편이 안전합니다.

🔧 제안 수정
 const handleSubmit = async () => {
-  if (!imageFile) return;
+  if (!imageFile || isSubmitting) return;
   const { fileUrl } = await uploadImage(imageFile);
   await applyToClub({ answers, feePaymentImageUrl: fileUrl });
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Club/Application/clubFeePage.tsx` around lines 50 - 54, Add a
reentrancy guard at the start of handleSubmit to prevent duplicate submissions:
introduce a local or component-level boolean (e.g., isSubmitting) that
handleSubmit checks immediately and returns if true, then set isSubmitting =
true before calling uploadImage/applyToClub and reset it in a finally block
(isSubmitting = false) so failures still clear the guard; reference
handleSubmit, imageFile, uploadImage, and applyToClub to locate where to add the
early-return check and the try/finally around the async calls.


return (
Expand Down Expand Up @@ -129,7 +123,7 @@ function ClubFeePage() {
onClick={handleSubmit}
disabled={!imageFile || isSubmitting}
>
제출하기
{isSubmitting ? '제출 중...' : '제출하기'}
</button>

{isImageOpen && previewUrl && (
Expand Down
20 changes: 5 additions & 15 deletions src/pages/Club/ClubDetail/components/ClubIntro.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { ClubDetailResponse } from '@/apis/club/entity';
import HumanIcon from '@/assets/svg/human.svg';
Expand All @@ -14,20 +13,11 @@ interface ClubIntroProps {

function ClubIntro({ clubDetail }: ClubIntroProps) {
const navigate = useNavigate();
const { createChatRoom } = useChat();

const [isSubmitting, setIsSubmitting] = useState(false);
const { createChatRoom, isCreatingChatRoom } = useChat();

const handleInquireClick = async () => {
if (isSubmitting) return;

try {
setIsSubmitting(true);
const response = await createChatRoom(clubDetail.presidentUserId);
navigate(`/chats/${response.chatRoomId}`);
} finally {
setIsSubmitting(false);
}
const response = await createChatRoom(clubDetail.presidentUserId);
navigate(`/chats/${response.chatRoomId}`);
};
Comment on lines 18 to 21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

에러 처리 부재: createChatRoom 실패 시 예외가 전파됩니다.

mutateAsync는 실패 시 예외를 throw하므로 try/catch 없이 사용하면 unhandled rejection이 발생할 수 있습니다.

🛡️ 에러 처리 추가 제안
   const handleInquireClick = async () => {
+    try {
       const response = await createChatRoom(clubDetail.presidentUserId);
       navigate(`/chats/${response.chatRoomId}`);
+    } catch {
+      // 에러 처리 (토스트 등)
+    }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleInquireClick = async () => {
if (isSubmitting) return;
try {
setIsSubmitting(true);
const response = await createChatRoom(clubDetail.presidentUserId);
navigate(`/chats/${response.chatRoomId}`);
} finally {
setIsSubmitting(false);
}
const response = await createChatRoom(clubDetail.presidentUserId);
navigate(`/chats/${response.chatRoomId}`);
};
const handleInquireClick = async () => {
try {
const response = await createChatRoom(clubDetail.presidentUserId);
navigate(`/chats/${response.chatRoomId}`);
} catch {
// 에러 처리 (토스트 등)
}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Club/ClubDetail/components/ClubIntro.tsx` around lines 18 - 21,
handleInquireClick currently calls createChatRoom(clubDetail.presidentUserId)
without error handling so any failure from mutateAsync will bubble up; wrap the
call in a try/catch inside handleInquireClick, await createChatRoom(...) in the
try, navigate(`/chats/${response.chatRoomId}`) on success, and handle failures
in the catch by logging the error (or showing a user-facing message/toast) and
avoiding an unhandled rejection.


return (
Expand Down Expand Up @@ -70,11 +60,11 @@ function ClubIntro({ clubDetail }: ClubIntroProps) {
<button
type="button"
onClick={handleInquireClick}
disabled={isSubmitting}
disabled={isCreatingChatRoom}
className="bg-primary text-body3 flex items-center justify-center gap-1 rounded-sm py-3 text-white"
>
<PaperPlaneIcon className="text-white" />
{isSubmitting ? '이동 중...' : '문의하기'}
{isCreatingChatRoom ? '이동 중...' : '문의하기'}
</button>
</Card>
</>
Expand Down
6 changes: 4 additions & 2 deletions src/pages/Manager/ManagedClubProfile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,12 @@ function ManagedClubInfo() {
<div className="text-h3 text-center whitespace-pre-wrap">동아리 정보를 수정하시겠어요?</div>
<div>
<button
type="button"
disabled={isPending || isUploading}
onClick={handleSubmit}
className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white"
className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white disabled:cursor-not-allowed disabled:opacity-50"
>
수정하기
{isUploading ? '수정 중...' : isPending ? '수정 중...' : '수정하기'}
</button>
<button onClick={closeSubmitModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400">
취소하기
Expand Down
5 changes: 4 additions & 1 deletion src/pages/Manager/ManagedRecruitment/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function ManagedRecruitment() {
const { clubId } = useParams<{ clubId: string }>();
const navigate = useNavigate();
const { data: settings } = useGetClubSettings(Number(clubId));
const { mutate: patchSettings } = usePatchClubSettings(Number(clubId));
const { mutate: patchSettings, isPending: isPatchingSettings } = usePatchClubSettings(Number(clubId));

const handleRecruitmentToggle = (value: boolean) => {
if (value && !settings?.recruitment) {
Expand Down Expand Up @@ -69,20 +69,23 @@ function ManagedRecruitment() {
icon={MegaphoneSmIcon}
label="모집공고"
enabled={settings?.isRecruitmentEnabled ?? false}
disabled={isPatchingSettings}
onChange={handleRecruitmentToggle}
/>
<div className="h-14 w-px bg-indigo-50" />
<ToggleSwitch
icon={FileSmIcon}
label="지원서"
enabled={settings?.isApplicationEnabled ?? false}
disabled={isPatchingSettings}
onChange={handleApplicationToggle}
/>
<div className="h-14 w-px bg-indigo-50" />
<ToggleSwitch
icon={CreditCardSmIcon}
label="회비"
enabled={settings?.isFeeEnabled ?? false}
disabled={isPatchingSettings}
onChange={handleFeeToggle}
/>
</div>
Expand Down
14 changes: 8 additions & 6 deletions src/pages/User/MyPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ const menuItems = [

function MyPage() {
const { myInfo } = useMyInfo();
const { mutate: logout } = useLogoutMutation();
const { mutate: logout, isPending: isLoggingOut } = useLogoutMutation();
const { value: isOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false);
const { mutate: goToAdminChat } = useAdminChatMutation();
const { mutate: goToAdminChat, isPending: isCreatingAdminChat } = useAdminChatMutation();

return (
<div className="flex flex-col gap-2 p-3">
Expand All @@ -46,13 +46,14 @@ function MyPage() {
))}

<button
disabled={isCreatingAdminChat}
onClick={() => goToAdminChat()}
className="bg-indigo-0 active:bg-indigo-5 w-full rounded-sm text-left transition-colors"
className="bg-indigo-0 active:bg-indigo-5 w-full rounded-sm text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-4">
<ChatIcon />
<div className="text-sub2">문의하기</div>
<div className="text-sub2">{isCreatingAdminChat ? '이동 중...' : '문의하기'}</div>
</div>
<RightArrowIcon />
</div>
Expand Down Expand Up @@ -80,10 +81,11 @@ function MyPage() {
<div className="text-h3 text-center whitespace-pre-wrap">정말로 로그아웃 하시겠어요?</div>
<div>
<button
disabled={isLoggingOut}
onClick={() => logout()}
className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white"
className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white disabled:cursor-not-allowed disabled:opacity-50"
>
로그아웃
{isLoggingOut ? '로그아웃 중...' : '로그아웃'}
</button>
<button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400">
취소
Expand Down
14 changes: 8 additions & 6 deletions src/pages/User/Profile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ const fields = [

function Profile() {
const { myInfo } = useMyInfo({});
const { mutate: withdraw } = useWithdrawMutation();
const { mutate: withdraw, isPending: isWithdrawing } = useWithdrawMutation();

const { value: isOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false);
const { mutate: goToAdminChat } = useAdminChatMutation();
const { mutate: goToAdminChat, isPending: isCreatingAdminChat } = useAdminChatMutation();

return (
<div className="flex flex-1 flex-col gap-2 bg-white px-5 py-6 pb-10">
Expand All @@ -42,10 +42,11 @@ function Profile() {
</div>

<button
disabled={isCreatingAdminChat}
onClick={() => goToAdminChat()}
className="bg-primary text-indigo-5 mt-auto w-full rounded-lg py-2.5 text-center text-lg leading-7 font-bold"
className="bg-primary text-indigo-5 mt-auto w-full rounded-lg py-2.5 text-center text-lg leading-7 font-bold disabled:cursor-not-allowed disabled:opacity-50"
>
문의하기
{isCreatingAdminChat ? '이동 중...' : '문의하기'}
</button>

<BottomModal isOpen={isOpen} onClose={closeModal}>
Expand All @@ -55,10 +56,11 @@ function Profile() {
</div>
<div>
<button
disabled={isWithdrawing}
onClick={() => withdraw()}
className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white"
className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white disabled:cursor-not-allowed disabled:opacity-50"
>
탈퇴하기
{isWithdrawing ? '탈퇴 처리 중...' : '탈퇴하기'}
</button>
<button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400">
취소
Expand Down