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
60 changes: 40 additions & 20 deletions src/components/Notification/NotificationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useDeleteNotification } from '@/hooks/useDeleteNotification';
import cn from '@/lib/cn';
import relativeTime from '@/utils/relativeTime';
import IconClose from '@assets/svg/close';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';

type NotificationStatus = 'confirmed' | 'declined';

Expand All @@ -12,6 +14,7 @@ interface NotificationCardProps {
id: number;
createdAt: string;
onDelete: (id: number) => void;
onCardClick?: () => void;
}

const statusColorMap: Record<
Expand Down Expand Up @@ -50,14 +53,16 @@ export default function NotificationCard({
createdAt,
id,
onDelete,
onCardClick,
}: NotificationCardProps) {
const { mutate: deleteNotification } = useDeleteNotification();
const { mutateAsync: deleteNotification } = useDeleteNotification();
const router = useRouter();

const formattedContent = content.replace(/(\(\d{4}-\d{2}-\d{2})\s+/, '$1\n');

const handleDelete = () => {
const handleDelete = async () => {
await deleteNotification(id);
onDelete(id);
deleteNotification(id);
};

const keywordMatch = Object.entries(statusKeywordMap).find(([k]) =>
Expand All @@ -66,6 +71,19 @@ export default function NotificationCard({

const status = keywordMatch?.[1];

const handleCardClick = async () => {
try {
await deleteNotification(id);
onDelete(id);
onCardClick?.();
router.push('/mypage/reservations');
} catch {
toast.error(
'알림 확인 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.',
);
}
};

return (
<div className='w-full rounded-[5px] border border-gray-400 bg-white px-12 py-16'>
<div className='flex items-start justify-between'>
Expand All @@ -79,8 +97,8 @@ export default function NotificationCard({
)}
<button
onClick={(e) => {
e.stopPropagation();
setTimeout(() => {
e.stopPropagation();
handleDelete();
}, 0);
}}
Comment on lines 99 to 104
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

버튼 타입을 명시해주세요.

정적 분석 도구에서 지적한 대로, button 요소에 명시적인 type을 지정해야 합니다.

다음과 같이 수정해주세요:

        <button
+          type="button"
          onClick={(e) => {
            e.stopPropagation();
            setTimeout(() => {
              handleDelete();
            }, 0);
          }}
          aria-label='알림 삭제'
        >
📝 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
onClick={(e) => {
e.stopPropagation();
setTimeout(() => {
e.stopPropagation();
handleDelete();
}, 0);
}}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setTimeout(() => {
handleDelete();
}, 0);
}}
aria-label='알림 삭제'
>
🤖 Prompt for AI Agents
In src/components/Notification/NotificationCard.tsx around lines 99 to 104, the
button element lacks an explicit type attribute, which can cause unintended form
submissions. Add a type="button" attribute to the button element to explicitly
specify its behavior and prevent default form submission.

Expand All @@ -90,23 +108,25 @@ export default function NotificationCard({
</button>
</div>

<p className='text-md font-regular whitespace-pre-line text-black'>
{formattedContent.split(/(승인|거절)/).map((text, i) => {
const matchedStatus = statusKeywordMap[text];
return (
<span
key={i}
className={cn(
matchedStatus && statusColorMap[matchedStatus].text,
)}
>
{text}
</span>
);
})}
</p>
<div className='cursor-pointer' onClick={handleCardClick}>
<p className='text-md font-regular whitespace-pre-line text-black'>
{formattedContent.split(/(승인|거절)/).map((text, i) => {
const matchedStatus = statusKeywordMap[text];
return (
<span
key={i}
className={cn(
matchedStatus && statusColorMap[matchedStatus].text,
)}
>
{text}
</span>
);
})}
</p>

<p className='mt-4 text-xs text-gray-600'>{relativeTime(createdAt)}</p>
<p className='mt-4 text-xs text-gray-600'>{relativeTime(createdAt)}</p>
</div>
Comment on lines +111 to +129
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

접근성 개선이 필요합니다.

클릭 가능한 div 요소에 적절한 접근성 속성을 추가해야 합니다. 키보드 네비게이션 지원과 적절한 역할(role)을 제공해주세요.

다음과 같이 수정해주세요:

-      <div className='cursor-pointer' onClick={handleCardClick}>
+      <div 
+        className='cursor-pointer' 
+        onClick={handleCardClick}
+        onKeyDown={(e) => {
+          if (e.key === 'Enter' || e.key === ' ') {
+            e.preventDefault();
+            handleCardClick();
+          }
+        }}
+        role="button"
+        tabIndex={0}
+        aria-label="알림 확인 및 예약 내역으로 이동"
+      >
📝 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
<div className='cursor-pointer' onClick={handleCardClick}>
<p className='text-md font-regular whitespace-pre-line text-black'>
{formattedContent.split(/(|)/).map((text, i) => {
const matchedStatus = statusKeywordMap[text];
return (
<span
key={i}
className={cn(
matchedStatus && statusColorMap[matchedStatus].text,
)}
>
{text}
</span>
);
})}
</p>
<p className='mt-4 text-xs text-gray-600'>{relativeTime(createdAt)}</p>
<p className='mt-4 text-xs text-gray-600'>{relativeTime(createdAt)}</p>
</div>
<div
className='cursor-pointer'
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCardClick();
}
}}
role="button"
tabIndex={0}
aria-label="알림 확인 및 예약 내역으로 이동"
>
<p className='text-md font-regular whitespace-pre-line text-black'>
{formattedContent.split(/(|)/).map((text, i) => {
const matchedStatus = statusKeywordMap[text];
return (
<span
key={i}
className={cn(
matchedStatus && statusColorMap[matchedStatus].text,
)}
>
{text}
</span>
);
})}
</p>
<p className='mt-4 text-xs text-gray-600'>{relativeTime(createdAt)}</p>
</div>
🧰 Tools
🪛 Biome (2.1.2)

[error] 126-128: Static Elements should not be interactive.

To add interactivity such as a mouse or key event listener to a static element, give the element an appropriate role value.

(lint/a11y/noStaticElementInteractions)


[error] 126-128: Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event.

Actions triggered using mouse events should have corresponding keyboard events to account for keyboard-only navigation.

(lint/a11y/useKeyWithClickEvents)


[error] 114-120: Provide an explicit type prop for the button element.

The default type of a button is submit, which causes the submission of a form when placed inside a form element. This is likely not the behaviour that you want inside a React application.
Allowed button types are: submit, button or reset

(lint/a11y/useButtonType)

🤖 Prompt for AI Agents
In src/components/Notification/NotificationCard.tsx around lines 111 to 129, the
clickable div lacks accessibility features. Replace the div with a button
element or add role="button", tabIndex="0", and keyboard event handlers (e.g.,
onKeyDown for Enter and Space keys) to support keyboard navigation and screen
readers. This will ensure the element is focusable and operable via keyboard,
improving accessibility.

</div>
);
}
3 changes: 3 additions & 0 deletions src/components/Notification/NotificationCardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { toast } from 'sonner';

type NotificationCardListProps = {
notification: Notification['notifications'];
onCardClick?: () => void;
};

/**
Expand All @@ -26,6 +27,7 @@ type NotificationCardListProps = {
*/
export default function NotificationCardList({
notification,
onCardClick,
}: NotificationCardListProps) {
// 현재 화면에 표시할 알림 목록 상태
const [currentNotifications, setCurrentNotifications] =
Expand Down Expand Up @@ -143,6 +145,7 @@ export default function NotificationCardList({
prev.filter((item) => item.id !== id),
)
}
onCardClick={onCardClick}
/>
))}

Expand Down
5 changes: 4 additions & 1 deletion src/components/Notification/NotificationDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ export default function NotificationDropdown({
)}

{!isLoading && data && (
<NotificationCardList notification={data.notifications} />
<NotificationCardList
notification={data.notifications}
onCardClick={onClose}
/>
)}
</div>
);
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/useDeleteNotification.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { privateInstance } from '@/apis/privateInstance';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

/**
* 특정 알림을 삭제하는 비동기 함수입니다.
Expand Down Expand Up @@ -40,5 +41,9 @@ export const useDeleteNotification = () => {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},

onError: () => {
toast.error('알림 삭제를 실패했습니다.');
},
});
};