From d8ec2cf674b859fe015ab6f5a91289a633009bd7 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 20:18:24 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/alter-logo-vector.svg | 9 ++ .../ui/notification/NotificationItem.tsx | 152 ++++++++++++++++++ .../stories/NotificationItem.stories.tsx | 75 +++++++++ tailwind.config.js | 6 +- 4 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/assets/alter-logo-vector.svg create mode 100644 src/shared/ui/notification/NotificationItem.tsx create mode 100644 storybook/stories/NotificationItem.stories.tsx diff --git a/src/assets/alter-logo-vector.svg b/src/assets/alter-logo-vector.svg new file mode 100644 index 0000000..31507ea --- /dev/null +++ b/src/assets/alter-logo-vector.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/shared/ui/notification/NotificationItem.tsx b/src/shared/ui/notification/NotificationItem.tsx new file mode 100644 index 0000000..1b247d2 --- /dev/null +++ b/src/shared/ui/notification/NotificationItem.tsx @@ -0,0 +1,152 @@ +import { useRef, useState } from 'react' +import TrashIcon from '@/assets/icons/social/trash.svg?react' +import AlterLogo from '@/assets/alter-logo-vector.svg?react' + +export interface NotificationItemProps { + isRead: boolean + category: string + timeAgo: string + message: string + highlightedWord?: string + subLabel?: string + onDelete?: () => void + onClick?: () => void +} + +const DELETE_WIDTH = 60 +const SWIPE_THRESHOLD = DELETE_WIDTH / 2 + +function HighlightedMessage({ + message, + highlightedWord, +}: { + message: string + highlightedWord?: string +}) { + if (!highlightedWord) return {message} + + const idx = message.indexOf(highlightedWord) + if (idx === -1) return {message} + + return ( + <> + {message.slice(0, idx)} + {highlightedWord} + {message.slice(idx + highlightedWord.length)} + + ) +} + +export function NotificationItem({ + isRead, + category, + timeAgo, + message, + highlightedWord, + subLabel, + onDelete, + onClick, +}: NotificationItemProps) { + const [offset, setOffset] = useState(0) + const startXRef = useRef(null) + const isDragging = useRef(false) + + const startDrag = (clientX: number) => { + if (!onDelete) return + startXRef.current = clientX + isDragging.current = true + } + + const moveDrag = (clientX: number) => { + if (!isDragging.current || startXRef.current === null) return + const diff = clientX - startXRef.current + setOffset(Math.min(0, Math.max(-DELETE_WIDTH, diff))) + } + + const endDrag = () => { + if (!isDragging.current) return + isDragging.current = false + setOffset(offset < -SWIPE_THRESHOLD ? -DELETE_WIDTH : 0) + startXRef.current = null + } + + const handleTouchStart = (e: React.TouchEvent) => + startDrag(e.touches[0].clientX) + const handleTouchMove = (e: React.TouchEvent) => + moveDrag(e.touches[0].clientX) + const handleTouchEnd = endDrag + + const handleMouseDown = (e: React.MouseEvent) => startDrag(e.clientX) + const handleMouseMove = (e: React.MouseEvent) => moveDrag(e.clientX) + const handleMouseUp = endDrag + + const handleDelete = () => { + setOffset(0) + onDelete?.() + } + + return ( +
+ {onDelete && ( + + )} + + +
+ ) +} diff --git a/storybook/stories/NotificationItem.stories.tsx b/storybook/stories/NotificationItem.stories.tsx new file mode 100644 index 0000000..9f7427a --- /dev/null +++ b/storybook/stories/NotificationItem.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import React from 'react' + +import { NotificationItem } from '../../src/shared/ui/notification/NotificationItem' + +const meta = { + title: 'shared/ui/notification/NotificationItem', + component: NotificationItem, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + category: '대타 요청', + timeAgo: '10시간 전', + message: '2월 4일 동양미래대에서 대타 요청이 도착했어요', + highlightedWord: '동양미래대', + subLabel: '자세히 보기', + isRead: false, + onClick: () => {}, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Unread: Story = { + name: '읽지 않음', + args: { isRead: false }, +} + +export const Read: Story = { + name: '읽음', + args: { + isRead: true, + timeAgo: '2일 전', + message: '3월 30일 KFC 잠실 롯데월드점 대타 요청을 거절했어요', + highlightedWord: 'KFC 잠실 롯데월드', + subLabel: undefined, + }, +} + +export const WithDeleteAction: Story = { + name: '삭제 액션', + args: { + isRead: true, + timeAgo: '2일 전', + message: '3월 30일 KFC 잠실 롯데월드점 대타 요청을 거절했어요', + highlightedWord: 'KFC 잠실 롯데월드', + subLabel: undefined, + onDelete: () => alert('삭제'), + }, +} + +export const LongStoreName: Story = { + name: '가게명 줄바꿈', + args: { + isRead: false, + message: '2월 4일 CU 서구가정로점에서 대타 요청이 도착했어요', + highlightedWord: 'CU 서구가정로', + subLabel: '자세히 보기', + }, +} diff --git a/tailwind.config.js b/tailwind.config.js index 29e635e..6a9fc3a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,10 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx}', + './storybook/**/*.{js,ts,jsx,tsx}', + ], theme: { extend: { // Custom breakpoints From e5947515ba2c5d203babee5c15c03caa4dbd6ca0 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 20:31:11 +0900 Subject: [PATCH 02/16] =?UTF-8?q?add:=20=EC=84=A4=EC=A0=95=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/settings.svg | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/assets/icons/settings.svg diff --git a/src/assets/icons/settings.svg b/src/assets/icons/settings.svg new file mode 100644 index 0000000..efc45e0 --- /dev/null +++ b/src/assets/icons/settings.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + From 51802aad4b508515b1e889942dfc4a36511f0bb5 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 20:33:28 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/useNotificationViewModel.ts | 26 +++++ src/pages/notification/index.tsx | 100 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/features/notification/useNotificationViewModel.ts create mode 100644 src/pages/notification/index.tsx diff --git a/src/features/notification/useNotificationViewModel.ts b/src/features/notification/useNotificationViewModel.ts new file mode 100644 index 0000000..797deca --- /dev/null +++ b/src/features/notification/useNotificationViewModel.ts @@ -0,0 +1,26 @@ +import { useState } from 'react' +import type { NotificationItemProps } from '@/shared/ui/notification/NotificationItem' + +export type NotificationTab = 'substitute' | 'reputation' + +// TODO: 로그인 계정 타입에 따라 다른 API 호출 +export function useNotificationViewModel() { + const [activeTab, setActiveTab] = useState('substitute') + + const substituteItems: NotificationItemProps[] = [] + const reputationItems: NotificationItemProps[] = [] + + const currentItems = + activeTab === 'substitute' ? substituteItems : reputationItems + + const hasUnreadSubstitute = substituteItems.some(item => !item.isRead) + const hasUnreadReputation = reputationItems.some(item => !item.isRead) + + return { + activeTab, + setActiveTab, + currentItems, + hasUnreadSubstitute, + hasUnreadReputation, + } +} diff --git a/src/pages/notification/index.tsx b/src/pages/notification/index.tsx new file mode 100644 index 0000000..d57ee17 --- /dev/null +++ b/src/pages/notification/index.tsx @@ -0,0 +1,100 @@ +import { useNavigate } from 'react-router-dom' +import { Navbar } from '@/shared/ui/common/Navbar' +import { NotificationItem } from '@/shared/ui/notification/NotificationItem' +import { useNotificationViewModel } from '@/features/notification/useNotificationViewModel' +import type { NotificationTab } from '@/features/notification/useNotificationViewModel' +import SettingIcon from '@/assets/icons/settings.svg?react' + +function TabButton({ + label, + active, + hasUnread, + onClick, +}: { + label: string + active: boolean + hasUnread?: boolean + onClick: () => void +}) { + return ( + + ) +} + +const TAB_LABELS: Record = { + substitute: '대타', + reputation: '평판', +} + +export function NotificationPage() { + const navigate = useNavigate() + const { + activeTab, + setActiveTab, + currentItems, + hasUnreadSubstitute, + hasUnreadReputation, + } = useNotificationViewModel() + + return ( +
+ navigate('/notifications/settings')} + > + + + } + /> + +
+ {(['substitute', 'reputation'] as NotificationTab[]).map(tab => ( + setActiveTab(tab)} + /> + ))} +
+ +
+ {currentItems.length === 0 ? ( +
+

+ 알림이 없습니다. +

+
+ ) : ( +
    + {currentItems.map((item, idx) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ) +} From 04036e3a0ae5730daa55cdc2341685da5df6c79c Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 20:33:48 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=97=A4=EB=8D=94=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 2 ++ src/shared/constants/routes.ts | 1 + src/shared/ui/common/Navbar.tsx | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/app/App.tsx b/src/app/App.tsx index b6285da..f8eaa65 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -26,6 +26,7 @@ import { SubstituteRequestPage } from '@/pages/user/substitute-request' import { StoreRegisterPage } from '@/pages/manager/store-register' import { ManagerWorkerInvitePage } from '@/pages/manager/worker-invite' import { WorkspaceJoinPage } from '@/pages/user/workspace-join' +import { NotificationPage } from '@/pages/notification' import { MyPage } from '@/pages/my' import { ProfileEditPage } from '@/pages/my/profile' import { ErrorPageRoute } from '@/pages/error' @@ -98,6 +99,7 @@ export function App() { element={} /> } /> + } /> } diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index e221f54..d935bc9 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -38,6 +38,7 @@ export const ROUTES = { ROOT: '/my', PROFILE: '/my/profile', }, + NOTIFICATIONS: '/notifications', } as const export function managerWorkerSchedulePath( diff --git a/src/shared/ui/common/Navbar.tsx b/src/shared/ui/common/Navbar.tsx index 368bf74..919a4a6 100644 --- a/src/shared/ui/common/Navbar.tsx +++ b/src/shared/ui/common/Navbar.tsx @@ -6,6 +6,7 @@ import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' import { useNavigate } from 'react-router-dom' import { HamburgerMenuDrawer } from '@/shared/ui/common/HamburgerMenuDrawer' import { cn } from '@/shared/lib/utils' +import { ROUTES } from '@/shared/constants/routes' type NavbarVariant = 'main' | 'detail' @@ -79,6 +80,7 @@ export function Navbar({ type="button" aria-label="알림" className="flex h-6 w-6 items-center justify-center" + onClick={() => navigate(ROUTES.NOTIFICATIONS)} > Bell From d2dbdfb1d3a1ffbd9ef42937581e5c05eb719163 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 20:42:00 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/useNotificationViewModel.ts | 74 ++++++++++++++++++- src/pages/notification/index.tsx | 2 +- .../ui/notification/NotificationItem.tsx | 44 ++++++----- 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/features/notification/useNotificationViewModel.ts b/src/features/notification/useNotificationViewModel.ts index 797deca..8fe351e 100644 --- a/src/features/notification/useNotificationViewModel.ts +++ b/src/features/notification/useNotificationViewModel.ts @@ -3,15 +3,83 @@ import type { NotificationItemProps } from '@/shared/ui/notification/Notificatio export type NotificationTab = 'substitute' | 'reputation' +type RawItem = Omit + +const SUBSTITUTE_DATA: RawItem[] = [ + { + isRead: false, + category: '대타 요청', + timeAgo: '10시간 전', + message: '2월 4일 CU 서구가정로점에서\n대타 요청이 도착했어요', + highlightedWord: 'CU 서구가정로', + subLabel: '자세히 보기', + }, + { + isRead: false, + category: '대타 요청', + timeAgo: '10시간 전', + message: '2월 4일 동양미래대에서 대타 요청이 도착했어요', + highlightedWord: '동양미래대', + subLabel: '자세히 보기', + }, + { + isRead: true, + category: '대타 요청', + timeAgo: '2일 전', + message: '3월 30일 KFC 잠실 롯데월드점 대타 요청을 거절했어요', + highlightedWord: 'KFC 잠실 롯데월드', + }, + { + isRead: true, + category: '대타 요청', + timeAgo: '3일 전', + message: '3월 28일 스타벅스 강남점 대타 요청이 만료됐어요', + highlightedWord: '스타벅스 강남', + }, +] + +const REPUTATION_DATA: RawItem[] = [ + { + isRead: false, + category: '평판', + timeAgo: '1일 전', + message: '김철수님이 회원님에게 평판을 남겼어요', + highlightedWord: '김철수', + subLabel: '자세히 보기', + }, + { + isRead: true, + category: '평판', + timeAgo: '5일 전', + message: '이영희님이 회원님에게 평판을 남겼어요', + highlightedWord: '이영희', + subLabel: '자세히 보기', + }, +] + // TODO: 로그인 계정 타입에 따라 다른 API 호출 export function useNotificationViewModel() { const [activeTab, setActiveTab] = useState('substitute') + const [substituteItems, setSubstituteItems] = + useState(SUBSTITUTE_DATA) + const [reputationItems, setReputationItems] = + useState(REPUTATION_DATA) + + const deleteSubstitute = (idx: number) => + setSubstituteItems(prev => prev.filter((_, i) => i !== idx)) - const substituteItems: NotificationItemProps[] = [] - const reputationItems: NotificationItemProps[] = [] + const deleteReputation = (idx: number) => + setReputationItems(prev => prev.filter((_, i) => i !== idx)) - const currentItems = + const currentItems: NotificationItemProps[] = ( activeTab === 'substitute' ? substituteItems : reputationItems + ).map((item, idx) => ({ + ...item, + onDelete: + activeTab === 'substitute' + ? () => deleteSubstitute(idx) + : () => deleteReputation(idx), + })) const hasUnreadSubstitute = substituteItems.some(item => !item.isRead) const hasUnreadReputation = reputationItems.some(item => !item.isRead) diff --git a/src/pages/notification/index.tsx b/src/pages/notification/index.tsx index d57ee17..353b498 100644 --- a/src/pages/notification/index.tsx +++ b/src/pages/notification/index.tsx @@ -26,7 +26,7 @@ function TabButton({ > {label} {hasUnread && ( - + )} ) diff --git a/src/shared/ui/notification/NotificationItem.tsx b/src/shared/ui/notification/NotificationItem.tsx index 1b247d2..c86de4d 100644 --- a/src/shared/ui/notification/NotificationItem.tsx +++ b/src/shared/ui/notification/NotificationItem.tsx @@ -50,6 +50,7 @@ export function NotificationItem({ const [offset, setOffset] = useState(0) const startXRef = useRef(null) const isDragging = useRef(false) + const offsetRef = useRef(0) const startDrag = (clientX: number) => { if (!onDelete) return @@ -60,14 +61,23 @@ export function NotificationItem({ const moveDrag = (clientX: number) => { if (!isDragging.current || startXRef.current === null) return const diff = clientX - startXRef.current - setOffset(Math.min(0, Math.max(-DELETE_WIDTH, diff))) + const next = Math.min(0, Math.max(-DELETE_WIDTH, diff)) + offsetRef.current = next + setOffset(next) } const endDrag = () => { if (!isDragging.current) return isDragging.current = false - setOffset(offset < -SWIPE_THRESHOLD ? -DELETE_WIDTH : 0) startXRef.current = null + if (offsetRef.current < -SWIPE_THRESHOLD) { + offsetRef.current = 0 + setOffset(0) + onDelete?.() + } else { + offsetRef.current = 0 + setOffset(0) + } } const handleTouchStart = (e: React.TouchEvent) => @@ -76,31 +86,34 @@ export function NotificationItem({ moveDrag(e.touches[0].clientX) const handleTouchEnd = endDrag - const handleMouseDown = (e: React.MouseEvent) => startDrag(e.clientX) - const handleMouseMove = (e: React.MouseEvent) => moveDrag(e.clientX) - const handleMouseUp = endDrag + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + startDrag(e.clientX) - const handleDelete = () => { - setOffset(0) - onDelete?.() + const onMove = (ev: MouseEvent) => moveDrag(ev.clientX) + const onUp = () => { + endDrag() + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) } return (
{onDelete && ( - +
)} diff --git a/src/pages/notification/settings/index.tsx b/src/pages/notification/settings/index.tsx new file mode 100644 index 0000000..af76b68 --- /dev/null +++ b/src/pages/notification/settings/index.tsx @@ -0,0 +1,74 @@ +import { Navbar } from '@/shared/ui/common/Navbar' +import { Toggle } from '@/shared/ui/common/Toggle' +import { useNotificationSettingsViewModel } from '@/features/notification/useNotificationSettingsViewModel' + +export function NotificationSettingsPage() { + const { + allEnabled, + substituteEnabled, + reputationEnabled, + handleAllChange, + setSubstituteEnabled, + setReputationEnabled, + } = useNotificationSettingsViewModel() + + return ( +
+ + +
+
+

전체 알림

+
+
+ + 전체 알림 켜기 + + +
+
+ + 대타 알림 켜기 + + +
+
+ + 평판 알림 켜기 + + +
+
+
+ +
+ +
+

시간 설정

+
+ + 방해금지 시간 + + + 23:00 ~ 08:00 + +
+
+
+
+ ) +} diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index d935bc9..31260d1 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -39,6 +39,7 @@ export const ROUTES = { PROFILE: '/my/profile', }, NOTIFICATIONS: '/notifications', + NOTIFICATION_SETTINGS: '/notifications/settings', } as const export function managerWorkerSchedulePath( diff --git a/src/shared/ui/common/Toggle.tsx b/src/shared/ui/common/Toggle.tsx new file mode 100644 index 0000000..7972104 --- /dev/null +++ b/src/shared/ui/common/Toggle.tsx @@ -0,0 +1,33 @@ +interface ToggleProps { + checked: boolean + onChange: (checked: boolean) => void + disabled?: boolean + ariaLabel?: string +} + +export function Toggle({ + checked, + onChange, + disabled, + ariaLabel, +}: ToggleProps) { + return ( + + ) +} From b5bb8e195c1963c820e255f2e2b4afac9474bf7e Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 20:59:30 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/api/notifications.ts | 36 ++++++ .../notification/hooks/useNotifications.ts | 21 +++ src/features/notification/types/index.ts | 22 ++++ .../notification/useNotificationViewModel.ts | 122 +++++++----------- src/pages/notification/index.tsx | 111 +++++++++++----- .../ui/notification/NotificationItem.tsx | 1 + 6 files changed, 206 insertions(+), 107 deletions(-) create mode 100644 src/features/notification/api/notifications.ts create mode 100644 src/features/notification/hooks/useNotifications.ts create mode 100644 src/features/notification/types/index.ts diff --git a/src/features/notification/api/notifications.ts b/src/features/notification/api/notifications.ts new file mode 100644 index 0000000..34b18bd --- /dev/null +++ b/src/features/notification/api/notifications.ts @@ -0,0 +1,36 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + NotificationListResponse, + NotificationQueryParams, +} from '@/features/notification/types' + +const DEFAULT_PAGE_SIZE = 20 + +function buildParams(params: NotificationQueryParams) { + return { + pageSize: params.pageSize ?? DEFAULT_PAGE_SIZE, + ...(params.cursor ? { cursor: params.cursor } : {}), + } +} + +/** GET /app/users/me/notifications */ +export async function fetchUserNotifications( + params: NotificationQueryParams = {} +): Promise { + const response = await axiosInstance.get( + '/app/users/me/notifications', + { params: buildParams(params) } + ) + return response.data +} + +/** GET /manager/notifications/me */ +export async function fetchManagerNotifications( + params: NotificationQueryParams = {} +): Promise { + const response = await axiosInstance.get( + '/manager/notifications/me', + { params: buildParams(params) } + ) + return response.data +} diff --git a/src/features/notification/hooks/useNotifications.ts b/src/features/notification/hooks/useNotifications.ts new file mode 100644 index 0000000..9c6d440 --- /dev/null +++ b/src/features/notification/hooks/useNotifications.ts @@ -0,0 +1,21 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { + fetchUserNotifications, + fetchManagerNotifications, +} from '@/features/notification/api/notifications' + +const PAGE_SIZE = 20 + +export function useNotifications(scope: 'MANAGER' | 'USER' | null) { + const fetcher = + scope === 'MANAGER' ? fetchManagerNotifications : fetchUserNotifications + + return useInfiniteQuery({ + queryKey: ['notifications', scope] as const, + queryFn: ({ pageParam }) => + fetcher({ pageSize: PAGE_SIZE, cursor: pageParam as string | undefined }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page.cursor || undefined, + enabled: scope !== null, + }) +} diff --git a/src/features/notification/types/index.ts b/src/features/notification/types/index.ts new file mode 100644 index 0000000..ea8f21e --- /dev/null +++ b/src/features/notification/types/index.ts @@ -0,0 +1,22 @@ +export interface NotificationDto { + id: number + title: string + body: string + createdAt: string +} + +export interface NotificationPage { + cursor: string + pageSize: number + totalCount: number +} + +export interface NotificationListResponse { + page: NotificationPage + data: NotificationDto[] +} + +export interface NotificationQueryParams { + cursor?: string + pageSize?: number +} diff --git a/src/features/notification/useNotificationViewModel.ts b/src/features/notification/useNotificationViewModel.ts index 8fe351e..c1ccb3e 100644 --- a/src/features/notification/useNotificationViewModel.ts +++ b/src/features/notification/useNotificationViewModel.ts @@ -1,94 +1,70 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' +import { useAuthStore } from '@/shared/stores/useAuthStore' +import { useNotifications } from '@/features/notification/hooks/useNotifications' import type { NotificationItemProps } from '@/shared/ui/notification/NotificationItem' +import type { NotificationDto } from '@/features/notification/types' export type NotificationTab = 'substitute' | 'reputation' -type RawItem = Omit +function formatTimeAgo(createdAt: string): string { + const diffMs = Date.now() - new Date(createdAt).getTime() + const minutes = Math.floor(diffMs / 60000) + const hours = Math.floor(diffMs / 3600000) + const days = Math.floor(diffMs / 86400000) -const SUBSTITUTE_DATA: RawItem[] = [ - { - isRead: false, - category: '대타 요청', - timeAgo: '10시간 전', - message: '2월 4일 CU 서구가정로점에서\n대타 요청이 도착했어요', - highlightedWord: 'CU 서구가정로', - subLabel: '자세히 보기', - }, - { - isRead: false, - category: '대타 요청', - timeAgo: '10시간 전', - message: '2월 4일 동양미래대에서 대타 요청이 도착했어요', - highlightedWord: '동양미래대', - subLabel: '자세히 보기', - }, - { - isRead: true, - category: '대타 요청', - timeAgo: '2일 전', - message: '3월 30일 KFC 잠실 롯데월드점 대타 요청을 거절했어요', - highlightedWord: 'KFC 잠실 롯데월드', - }, - { - isRead: true, - category: '대타 요청', - timeAgo: '3일 전', - message: '3월 28일 스타벅스 강남점 대타 요청이 만료됐어요', - highlightedWord: '스타벅스 강남', - }, -] + if (minutes < 60) return `${minutes}분 전` + if (hours < 24) return `${hours}시간 전` + return `${days}일 전` +} -const REPUTATION_DATA: RawItem[] = [ - { +function mapDto(dto: NotificationDto): Omit { + return { + id: dto.id, isRead: false, - category: '평판', - timeAgo: '1일 전', - message: '김철수님이 회원님에게 평판을 남겼어요', - highlightedWord: '김철수', - subLabel: '자세히 보기', - }, - { - isRead: true, - category: '평판', - timeAgo: '5일 전', - message: '이영희님이 회원님에게 평판을 남겼어요', - highlightedWord: '이영희', - subLabel: '자세히 보기', - }, -] + category: dto.title, + timeAgo: formatTimeAgo(dto.createdAt), + message: dto.body, + } +} -// TODO: 로그인 계정 타입에 따라 다른 API 호출 export function useNotificationViewModel() { const [activeTab, setActiveTab] = useState('substitute') - const [substituteItems, setSubstituteItems] = - useState(SUBSTITUTE_DATA) - const [reputationItems, setReputationItems] = - useState(REPUTATION_DATA) + const [deletedIds, setDeletedIds] = useState>(new Set()) - const deleteSubstitute = (idx: number) => - setSubstituteItems(prev => prev.filter((_, i) => i !== idx)) + const scope = useAuthStore(s => s.scope) + const { + data, + isLoading, + isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useNotifications(scope) - const deleteReputation = (idx: number) => - setReputationItems(prev => prev.filter((_, i) => i !== idx)) + const currentItems: NotificationItemProps[] = useMemo(() => { + const dtos = + data?.pages + .flatMap(page => page.data ?? []) + .filter(dto => !deletedIds.has(dto.id)) ?? [] - const currentItems: NotificationItemProps[] = ( - activeTab === 'substitute' ? substituteItems : reputationItems - ).map((item, idx) => ({ - ...item, - onDelete: - activeTab === 'substitute' - ? () => deleteSubstitute(idx) - : () => deleteReputation(idx), - })) + return dtos.map(dto => ({ + ...mapDto(dto), + onDelete: () => setDeletedIds(prev => new Set([...prev, dto.id])), + })) + }, [data, deletedIds]) - const hasUnreadSubstitute = substituteItems.some(item => !item.isRead) - const hasUnreadReputation = reputationItems.some(item => !item.isRead) + const hasUnread = currentItems.some(item => !item.isRead) return { activeTab, setActiveTab, currentItems, - hasUnreadSubstitute, - hasUnreadReputation, + hasUnreadSubstitute: hasUnread, + hasUnreadReputation: hasUnread, + isLoading, + isError, + fetchNextPage, + hasNextPage: Boolean(hasNextPage), + isFetchingNextPage, } } diff --git a/src/pages/notification/index.tsx b/src/pages/notification/index.tsx index a414770..6704e7a 100644 --- a/src/pages/notification/index.tsx +++ b/src/pages/notification/index.tsx @@ -1,5 +1,7 @@ +import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Navbar } from '@/shared/ui/common/Navbar' +import { Spinner } from '@/shared/ui/Spinner' import { NotificationItem } from '@/shared/ui/notification/NotificationItem' import { useNotificationViewModel } from '@/features/notification/useNotificationViewModel' import type { NotificationTab } from '@/features/notification/useNotificationViewModel' @@ -46,54 +48,95 @@ export function NotificationPage() { currentItems, hasUnreadSubstitute, hasUnreadReputation, + isLoading, + isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = useNotificationViewModel() + const sentinelRef = useRef(null) + + useEffect(() => { + const el = sentinelRef.current + if (!el) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, + { threshold: 0.1 } + ) + + observer.observe(el) + return () => observer.disconnect() + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + return (
- navigate(ROUTES.NOTIFICATION_SETTINGS)} - > - - - } - /> +
+ navigate(ROUTES.NOTIFICATION_SETTINGS)} + > + + + } + /> -
- {(['substitute', 'reputation'] as NotificationTab[]).map(tab => ( - setActiveTab(tab)} - /> - ))} +
+ {(['substitute', 'reputation'] as NotificationTab[]).map(tab => ( + setActiveTab(tab)} + /> + ))} +
- {currentItems.length === 0 ? ( + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ 알림을 불러오지 못했습니다. +

+
+ ) : currentItems.length === 0 ? (

알림이 없습니다.

) : ( -
    - {currentItems.map((item, idx) => ( -
  • - -
  • - ))} -
+ <> +
    + {currentItems.map((item, idx) => ( +
  • + +
  • + ))} +
+
+ {isFetchingNextPage && } +
+ )}
diff --git a/src/shared/ui/notification/NotificationItem.tsx b/src/shared/ui/notification/NotificationItem.tsx index c86de4d..f67ced2 100644 --- a/src/shared/ui/notification/NotificationItem.tsx +++ b/src/shared/ui/notification/NotificationItem.tsx @@ -3,6 +3,7 @@ import TrashIcon from '@/assets/icons/social/trash.svg?react' import AlterLogo from '@/assets/alter-logo-vector.svg?react' export interface NotificationItemProps { + id?: number isRead: boolean category: string timeAgo: string From 441436be25b164f6717e3a3dac877df730d7e4e2 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 21:21:46 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/api/notificationConsent.ts | 35 +++++++++++++++++++ .../hooks/useNotificationConsent.ts | 20 +++++++++++ .../notification/hooks/useNotifications.ts | 3 +- .../hooks/useUpdateNotificationConsent.ts | 24 +++++++++++++ src/features/notification/types/consent.ts | 26 ++++++++++++++ .../useNotificationSettingsViewModel.ts | 23 ++++++++++-- src/pages/notification/settings/index.tsx | 13 +++++++ src/shared/lib/queryKeys.ts | 6 ++++ 8 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 src/features/notification/api/notificationConsent.ts create mode 100644 src/features/notification/hooks/useNotificationConsent.ts create mode 100644 src/features/notification/hooks/useUpdateNotificationConsent.ts create mode 100644 src/features/notification/types/consent.ts diff --git a/src/features/notification/api/notificationConsent.ts b/src/features/notification/api/notificationConsent.ts new file mode 100644 index 0000000..aa34903 --- /dev/null +++ b/src/features/notification/api/notificationConsent.ts @@ -0,0 +1,35 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + NotificationConsentResponse, + UpdateNotificationConsentRequest, +} from '@/features/notification/types/consent' + +/** GET /app/users/me/notification-consent */ +export async function fetchUserNotificationConsent(): Promise { + const response = await axiosInstance.get( + '/app/users/me/notification-consent' + ) + return response.data +} + +/** GET /manager/me/notification-consent */ +export async function fetchManagerNotificationConsent(): Promise { + const response = await axiosInstance.get( + '/manager/me/notification-consent' + ) + return response.data +} + +/** PUT /app/users/me/notification-consent */ +export async function updateUserNotificationConsent( + body: UpdateNotificationConsentRequest +): Promise { + await axiosInstance.put('/app/users/me/notification-consent', body) +} + +/** PUT /manager/me/notification-consent */ +export async function updateManagerNotificationConsent( + body: UpdateNotificationConsentRequest +): Promise { + await axiosInstance.put('/manager/me/notification-consent', body) +} diff --git a/src/features/notification/hooks/useNotificationConsent.ts b/src/features/notification/hooks/useNotificationConsent.ts new file mode 100644 index 0000000..ca2617f --- /dev/null +++ b/src/features/notification/hooks/useNotificationConsent.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' +import { + fetchUserNotificationConsent, + fetchManagerNotificationConsent, +} from '@/features/notification/api/notificationConsent' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useNotificationConsent(scope: 'MANAGER' | 'USER' | null) { + const fetcher = + scope === 'MANAGER' + ? fetchManagerNotificationConsent + : fetchUserNotificationConsent + + return useQuery({ + queryKey: queryKeys.notification.consent(scope), + queryFn: fetcher, + enabled: scope !== null, + staleTime: 1000 * 60 * 60, + }) +} diff --git a/src/features/notification/hooks/useNotifications.ts b/src/features/notification/hooks/useNotifications.ts index 9c6d440..125968e 100644 --- a/src/features/notification/hooks/useNotifications.ts +++ b/src/features/notification/hooks/useNotifications.ts @@ -3,6 +3,7 @@ import { fetchUserNotifications, fetchManagerNotifications, } from '@/features/notification/api/notifications' +import { queryKeys } from '@/shared/lib/queryKeys' const PAGE_SIZE = 20 @@ -11,7 +12,7 @@ export function useNotifications(scope: 'MANAGER' | 'USER' | null) { scope === 'MANAGER' ? fetchManagerNotifications : fetchUserNotifications return useInfiniteQuery({ - queryKey: ['notifications', scope] as const, + queryKey: queryKeys.notification.list(scope), queryFn: ({ pageParam }) => fetcher({ pageSize: PAGE_SIZE, cursor: pageParam as string | undefined }), initialPageParam: undefined as string | undefined, diff --git a/src/features/notification/hooks/useUpdateNotificationConsent.ts b/src/features/notification/hooks/useUpdateNotificationConsent.ts new file mode 100644 index 0000000..35a52f2 --- /dev/null +++ b/src/features/notification/hooks/useUpdateNotificationConsent.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + updateUserNotificationConsent, + updateManagerNotificationConsent, +} from '@/features/notification/api/notificationConsent' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useUpdateNotificationConsent(scope: 'MANAGER' | 'USER' | null) { + const queryClient = useQueryClient() + + const updater = + scope === 'MANAGER' + ? updateManagerNotificationConsent + : updateUserNotificationConsent + + return useMutation({ + mutationFn: updater, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.notification.consent(scope), + }) + }, + }) +} diff --git a/src/features/notification/types/consent.ts b/src/features/notification/types/consent.ts new file mode 100644 index 0000000..6df9fd9 --- /dev/null +++ b/src/features/notification/types/consent.ts @@ -0,0 +1,26 @@ +export interface NotificationConsentType { + value: string + description: string +} + +export interface NotificationConsentItem { + type: NotificationConsentType + consent: boolean +} + +export interface NotificationConsentResponse { + timestamp: string + data: { + items: NotificationConsentItem[] + } +} + +export interface UpdateNotificationConsentRequest { + type: string + consent: boolean +} + +export const CONSENT_TYPE = { + GENERAL: 'GENERAL', + NIGHT: 'NIGHT', +} as const diff --git a/src/features/notification/useNotificationSettingsViewModel.ts b/src/features/notification/useNotificationSettingsViewModel.ts index 8defe33..f741be2 100644 --- a/src/features/notification/useNotificationSettingsViewModel.ts +++ b/src/features/notification/useNotificationSettingsViewModel.ts @@ -1,17 +1,36 @@ import { useState } from 'react' +import { useAuthStore } from '@/shared/stores/useAuthStore' +import { useNotificationConsent } from '@/features/notification/hooks/useNotificationConsent' +import { useUpdateNotificationConsent } from '@/features/notification/hooks/useUpdateNotificationConsent' +import { CONSENT_TYPE } from '@/features/notification/types/consent' + +function getConsent( + items: Array<{ type: { value: string }; consent: boolean }>, + typeValue: string +): boolean { + return items.find(i => i.type.value === typeValue)?.consent ?? true +} export function useNotificationSettingsViewModel() { - const [allEnabled, setAllEnabled] = useState(true) + const scope = useAuthStore(s => s.scope) + + const { data, isLoading } = useNotificationConsent(scope) + const { mutate } = useUpdateNotificationConsent(scope) + + const items = data?.data.items ?? [] + const allEnabled = getConsent(items, CONSENT_TYPE.GENERAL) + const [substituteEnabled, setSubstituteEnabled] = useState(true) const [reputationEnabled, setReputationEnabled] = useState(true) const handleAllChange = (checked: boolean) => { - setAllEnabled(checked) + mutate({ type: CONSENT_TYPE.GENERAL, consent: checked }) setSubstituteEnabled(checked) setReputationEnabled(checked) } return { + isLoading, allEnabled, substituteEnabled, reputationEnabled, diff --git a/src/pages/notification/settings/index.tsx b/src/pages/notification/settings/index.tsx index af76b68..097c4b0 100644 --- a/src/pages/notification/settings/index.tsx +++ b/src/pages/notification/settings/index.tsx @@ -1,9 +1,11 @@ import { Navbar } from '@/shared/ui/common/Navbar' +import { Spinner } from '@/shared/ui/Spinner' import { Toggle } from '@/shared/ui/common/Toggle' import { useNotificationSettingsViewModel } from '@/features/notification/useNotificationSettingsViewModel' export function NotificationSettingsPage() { const { + isLoading, allEnabled, substituteEnabled, reputationEnabled, @@ -12,6 +14,17 @@ export function NotificationSettingsPage() { setReputationEnabled, } = useNotificationSettingsViewModel() + if (isLoading) { + return ( +
+ +
+ +
+
+ ) + } + return (
diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 01322e7..a558961 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -86,4 +86,10 @@ export const queryKeys = { list: (workspaceId: number) => ['fixedWorkerSchedule', 'list', workspaceId] as const, }, + notification: { + list: (scope: 'MANAGER' | 'USER' | null) => + ['notifications', scope] as const, + consent: (scope: 'MANAGER' | 'USER' | null) => + ['notificationConsent', scope] as const, + }, } as const From d47d9ab7ad16df11734a166cc0f58613a2c83660 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 21:41:51 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix:=20NotificationPage=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/notification/types/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/notification/types/index.ts b/src/features/notification/types/index.ts index ea8f21e..024b29f 100644 --- a/src/features/notification/types/index.ts +++ b/src/features/notification/types/index.ts @@ -6,7 +6,7 @@ export interface NotificationDto { } export interface NotificationPage { - cursor: string + cursor: string | null pageSize: number totalCount: number } From 5a716993d989d8b0b8b498699f689ffccdd9c105 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 21:43:09 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20scope=EA=B0=80=20null=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=20=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD=20mutation?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useUpdateNotificationConsent.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/features/notification/hooks/useUpdateNotificationConsent.ts b/src/features/notification/hooks/useUpdateNotificationConsent.ts index 35a52f2..665ea3b 100644 --- a/src/features/notification/hooks/useUpdateNotificationConsent.ts +++ b/src/features/notification/hooks/useUpdateNotificationConsent.ts @@ -1,4 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { UpdateNotificationConsentRequest } from '@/features/notification/types/consent' import { updateUserNotificationConsent, updateManagerNotificationConsent, @@ -8,13 +9,13 @@ import { queryKeys } from '@/shared/lib/queryKeys' export function useUpdateNotificationConsent(scope: 'MANAGER' | 'USER' | null) { const queryClient = useQueryClient() - const updater = - scope === 'MANAGER' - ? updateManagerNotificationConsent - : updateUserNotificationConsent - return useMutation({ - mutationFn: updater, + mutationFn: (body: UpdateNotificationConsentRequest) => { + if (scope === null) return Promise.resolve() + return scope === 'MANAGER' + ? updateManagerNotificationConsent(body) + : updateUserNotificationConsent(body) + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.notification.consent(scope), From c2e45de45dd72f75a4c010802bab85fbec679711 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 21:46:31 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20=EB=A7=88=EC=9A=B0=EC=8A=A4=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/notification/NotificationItem.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/shared/ui/notification/NotificationItem.tsx b/src/shared/ui/notification/NotificationItem.tsx index f67ced2..6cbec5c 100644 --- a/src/shared/ui/notification/NotificationItem.tsx +++ b/src/shared/ui/notification/NotificationItem.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import TrashIcon from '@/assets/icons/social/trash.svg?react' import AlterLogo from '@/assets/alter-logo-vector.svg?react' @@ -52,16 +52,36 @@ export function NotificationItem({ const startXRef = useRef(null) const isDragging = useRef(false) const offsetRef = useRef(0) + const didDrag = useRef(false) + const moveListenerRef = useRef<((e: MouseEvent) => void) | null>(null) + const upListenerRef = useRef<(() => void) | null>(null) + + useEffect(() => { + return () => { + if (moveListenerRef.current) { + document.removeEventListener('mousemove', moveListenerRef.current) + moveListenerRef.current = null + } + if (upListenerRef.current) { + document.removeEventListener('mouseup', upListenerRef.current) + upListenerRef.current = null + } + endDrag() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const startDrag = (clientX: number) => { if (!onDelete) return startXRef.current = clientX isDragging.current = true + didDrag.current = false } const moveDrag = (clientX: number) => { if (!isDragging.current || startXRef.current === null) return const diff = clientX - startXRef.current + if (Math.abs(diff) > 2) didDrag.current = true const next = Math.min(0, Math.max(-DELETE_WIDTH, diff)) offsetRef.current = next setOffset(next) @@ -96,7 +116,11 @@ export function NotificationItem({ endDrag() document.removeEventListener('mousemove', onMove) document.removeEventListener('mouseup', onUp) + moveListenerRef.current = null + upListenerRef.current = null } + moveListenerRef.current = onMove + upListenerRef.current = onUp document.addEventListener('mousemove', onMove) document.addEventListener('mouseup', onUp) } @@ -125,6 +149,10 @@ export function NotificationItem({ onTouchEnd={handleTouchEnd} onMouseDown={handleMouseDown} onClick={() => { + if (didDrag.current) { + didDrag.current = false + return + } if (offset !== 0) { setOffset(0) return From d67fd9d283db53bd7c6489e7dc88c6e4b089f5f1 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 21:49:23 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20=EC=84=A4=EC=A0=95=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9C=BC=EB=A1=9C=20CONSENT=5FTYPE=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/notification/types/consent.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/features/notification/types/consent.ts b/src/features/notification/types/consent.ts index 6df9fd9..2f2af2a 100644 --- a/src/features/notification/types/consent.ts +++ b/src/features/notification/types/consent.ts @@ -1,3 +1,10 @@ +export const CONSENT_TYPE = { + GENERAL: 'GENERAL', + NIGHT: 'NIGHT', +} as const + +export type ConsentType = (typeof CONSENT_TYPE)[keyof typeof CONSENT_TYPE] + export interface NotificationConsentType { value: string description: string @@ -16,11 +23,6 @@ export interface NotificationConsentResponse { } export interface UpdateNotificationConsentRequest { - type: string + type: ConsentType consent: boolean } - -export const CONSENT_TYPE = { - GENERAL: 'GENERAL', - NIGHT: 'NIGHT', -} as const From 56aa0a273748144a86e3b365b9947834bd167145 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 20 May 2026 22:25:27 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/notification/NotificationItem.tsx | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/shared/ui/notification/NotificationItem.tsx b/src/shared/ui/notification/NotificationItem.tsx index 6cbec5c..8e4cf4f 100644 --- a/src/shared/ui/notification/NotificationItem.tsx +++ b/src/shared/ui/notification/NotificationItem.tsx @@ -56,6 +56,19 @@ export function NotificationItem({ const moveListenerRef = useRef<((e: MouseEvent) => void) | null>(null) const upListenerRef = useRef<(() => void) | null>(null) + const endDrag = () => { + if (!isDragging.current) return + isDragging.current = false + startXRef.current = null + if (offsetRef.current < -SWIPE_THRESHOLD) { + offsetRef.current = -DELETE_WIDTH + setOffset(-DELETE_WIDTH) + } else { + offsetRef.current = 0 + setOffset(0) + } + } + useEffect(() => { return () => { if (moveListenerRef.current) { @@ -68,7 +81,6 @@ export function NotificationItem({ } endDrag() } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const startDrag = (clientX: number) => { @@ -87,18 +99,10 @@ export function NotificationItem({ setOffset(next) } - const endDrag = () => { - if (!isDragging.current) return - isDragging.current = false - startXRef.current = null - if (offsetRef.current < -SWIPE_THRESHOLD) { - offsetRef.current = 0 - setOffset(0) - onDelete?.() - } else { - offsetRef.current = 0 - setOffset(0) - } + const handleDelete = () => { + offsetRef.current = 0 + setOffset(0) + onDelete?.() } const handleTouchStart = (e: React.TouchEvent) => @@ -128,12 +132,14 @@ export function NotificationItem({ return (
{onDelete && ( -
-
+ )} + + {isOpen && ( +
    + {ALL_TYPES.map(type => ( +
  • + +
  • + ))} +
)} - +
) } -const TAB_LABELS: Record = { - substitute: '대타', - reputation: '평판', -} - export function NotificationPage() { const navigate = useNavigate() const { - activeTab, - setActiveTab, + selectedType, + setSelectedType, currentItems, - hasUnreadSubstitute, - hasUnreadReputation, isLoading, isError, fetchNextPage, @@ -92,18 +139,11 @@ export function NotificationPage() { } /> -
- {(['substitute', 'reputation'] as NotificationTab[]).map(tab => ( - setActiveTab(tab)} - /> - ))} +
+