diff --git a/src/app/App.tsx b/src/app/App.tsx index 9b94a5a..956f495 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -29,6 +29,8 @@ import { StoreRegisterPage } from '@/pages/manager/store-register' import { ManagerWorkerInvitePage } from '@/pages/manager/worker-invite' import { WorkerListPage } from '@/pages/manager/worker-list' import { WorkspaceJoinPage } from '@/pages/user/workspace-join' +import { NotificationPage } from '@/pages/notification' +import { NotificationSettingsPage } from '@/pages/notification/settings' import { MyPage } from '@/pages/my' import { ProfileEditPage } from '@/pages/my/profile' import { EmailEditPage } from '@/pages/my/profile/email' @@ -110,6 +112,11 @@ export function App() { element={} /> } /> + } /> + } + /> } 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/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 @@ + + + + + + + + + + + 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/api/notifications.ts b/src/features/notification/api/notifications.ts new file mode 100644 index 0000000..f9a11ba --- /dev/null +++ b/src/features/notification/api/notifications.ts @@ -0,0 +1,76 @@ +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 } : {}), + ...(params.type ? { type: params.type } : {}), + } +} + +/** 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 +} + +export async function markUserNotificationsRead(notificationId: number | null) { + const response = await axiosInstance.patch( + '/app/users/me/notifications/read', + { + notificationId, + } + ) + return response.data +} + +export async function markManagerNotificationsRead( + notificationId: number | null +) { + const response = await axiosInstance.patch('/manager/notifications/read', { + notificationId, + }) + return response.data +} + +export async function fetchUserNotificationUnreadCount(): Promise<{ + unreadCount: number + hasUnread: boolean +}> { + const response = await axiosInstance.get<{ + data: { unreadCount: number; hasUnread: boolean } + }>('/app/users/me/notifications/unread-count') + return response.data.data +} + +export async function fetchManagerNotificationUnreadCount(): Promise<{ + unreadCount: number + hasUnread: boolean +}> { + const response = await axiosInstance.get<{ + data: { unreadCount: number; hasUnread: boolean } + }>('/manager/notifications/me/unread-count') + return response.data.data +} diff --git a/src/features/notification/hooks/useMarkNotificationRead.ts b/src/features/notification/hooks/useMarkNotificationRead.ts new file mode 100644 index 0000000..fd66e98 --- /dev/null +++ b/src/features/notification/hooks/useMarkNotificationRead.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + markUserNotificationsRead, + markManagerNotificationsRead, +} from '@/features/notification/api/notifications' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useMarkNotificationRead(scope: 'MANAGER' | 'USER' | null) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (notificationId: number | null) => { + if (scope === null) return Promise.resolve() + return scope === 'MANAGER' + ? markManagerNotificationsRead(notificationId) + : markUserNotificationsRead(notificationId) + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.notification.list(scope, undefined), + }) + }, + }) +} 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/useNotificationUnreadCount.ts b/src/features/notification/hooks/useNotificationUnreadCount.ts new file mode 100644 index 0000000..6e7a781 --- /dev/null +++ b/src/features/notification/hooks/useNotificationUnreadCount.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query' +import { + fetchUserNotificationUnreadCount, + fetchManagerNotificationUnreadCount, +} from '@/features/notification/api/notifications' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useNotificationUnreadCount(scope: 'MANAGER' | 'USER' | null) { + const fetcher = + scope === 'MANAGER' + ? fetchManagerNotificationUnreadCount + : fetchUserNotificationUnreadCount + + return useQuery({ + queryKey: queryKeys.notification.unreadCount(scope), + queryFn: fetcher, + enabled: scope !== null, + }) +} diff --git a/src/features/notification/hooks/useNotifications.ts b/src/features/notification/hooks/useNotifications.ts new file mode 100644 index 0000000..49cdf4b --- /dev/null +++ b/src/features/notification/hooks/useNotifications.ts @@ -0,0 +1,29 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { + fetchUserNotifications, + fetchManagerNotifications, +} from '@/features/notification/api/notifications' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 20 + +export function useNotifications( + scope: 'MANAGER' | 'USER' | null, + type?: string +) { + const fetcher = + scope === 'MANAGER' ? fetchManagerNotifications : fetchUserNotifications + + return useInfiniteQuery({ + queryKey: queryKeys.notification.list(scope, type), + queryFn: ({ pageParam }) => + fetcher({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + type, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page.cursor || undefined, + enabled: scope !== null, + }) +} diff --git a/src/features/notification/hooks/useUpdateNotificationConsent.ts b/src/features/notification/hooks/useUpdateNotificationConsent.ts new file mode 100644 index 0000000..665ea3b --- /dev/null +++ b/src/features/notification/hooks/useUpdateNotificationConsent.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { UpdateNotificationConsentRequest } from '@/features/notification/types/consent' +import { + updateUserNotificationConsent, + updateManagerNotificationConsent, +} from '@/features/notification/api/notificationConsent' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useUpdateNotificationConsent(scope: 'MANAGER' | 'USER' | null) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (body: UpdateNotificationConsentRequest) => { + if (scope === null) return Promise.resolve() + return scope === 'MANAGER' + ? updateManagerNotificationConsent(body) + : updateUserNotificationConsent(body) + }, + 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..2f2af2a --- /dev/null +++ b/src/features/notification/types/consent.ts @@ -0,0 +1,28 @@ +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 +} + +export interface NotificationConsentItem { + type: NotificationConsentType + consent: boolean +} + +export interface NotificationConsentResponse { + timestamp: string + data: { + items: NotificationConsentItem[] + } +} + +export interface UpdateNotificationConsentRequest { + type: ConsentType + consent: boolean +} diff --git a/src/features/notification/types/index.ts b/src/features/notification/types/index.ts new file mode 100644 index 0000000..de77c22 --- /dev/null +++ b/src/features/notification/types/index.ts @@ -0,0 +1,28 @@ +export interface NotificationDto { + id: number + type: { + value: string + description: string + } + title: string + body: string + createdAt: string + read: boolean +} + +export interface NotificationPage { + cursor: string | null + pageSize: number + totalCount: number +} + +export interface NotificationListResponse { + page: NotificationPage + data: NotificationDto[] +} + +export interface NotificationQueryParams { + cursor?: string + pageSize?: number + type?: string +} diff --git a/src/features/notification/types/notificationType.ts b/src/features/notification/types/notificationType.ts new file mode 100644 index 0000000..cd49389 --- /dev/null +++ b/src/features/notification/types/notificationType.ts @@ -0,0 +1,13 @@ +export const NOTIFICATION_TYPE = { + GENERAL: 'GENERAL', + SCHEDULE: 'SCHEDULE', + SUBSTITUTE: 'SUBSTITUTE', + REPUTATION: 'REPUTATION', + POSTING_APPLICATION: 'POSTING_APPLICATION', + CHAT: 'CHAT', + WORKSPACE_INVITATION: 'WORKSPACE_INVITATION', + JOIN_REQUEST: 'JOIN_REQUEST', +} as const + +export type NotificationType = + (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE] diff --git a/src/features/notification/useNotificationSettingsViewModel.ts b/src/features/notification/useNotificationSettingsViewModel.ts new file mode 100644 index 0000000..e452a3c --- /dev/null +++ b/src/features/notification/useNotificationSettingsViewModel.ts @@ -0,0 +1,48 @@ +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 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 nightEnabled = getConsent(items, CONSENT_TYPE.NIGHT) + + const [substituteEnabled, setSubstituteEnabled] = useState(true) + const [reputationEnabled, setReputationEnabled] = useState(true) + + const handleAllChange = (checked: boolean) => { + mutate({ type: CONSENT_TYPE.GENERAL, consent: checked }) + setSubstituteEnabled(checked) + setReputationEnabled(checked) + } + + const handleNightChange = (checked: boolean) => { + mutate({ type: CONSENT_TYPE.NIGHT, consent: checked }) + } + + return { + isLoading, + allEnabled, + nightEnabled, + substituteEnabled, + reputationEnabled, + handleAllChange, + handleNightChange, + setSubstituteEnabled, + setReputationEnabled, + } +} diff --git a/src/features/notification/useNotificationViewModel.ts b/src/features/notification/useNotificationViewModel.ts new file mode 100644 index 0000000..2603ad4 --- /dev/null +++ b/src/features/notification/useNotificationViewModel.ts @@ -0,0 +1,93 @@ +import { useMemo, useState } from 'react' +import { useAuthStore } from '@/shared/stores/useAuthStore' +import { useNotifications } from '@/features/notification/hooks/useNotifications' +import { useMarkNotificationRead } from '@/features/notification/hooks/useMarkNotificationRead' +import { + NOTIFICATION_TYPE, + type NotificationType, +} from '@/features/notification/types/notificationType' +import type { NotificationItemProps } from '@/shared/ui/notification/NotificationItem' +import type { NotificationDto } from '@/features/notification/types' + +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) + + if (minutes < 60) return `${minutes}분 전` + if (hours < 24) return `${hours}시간 전` + return `${days}일 전` +} + +function mapDto(dto: NotificationDto): Omit { + return { + id: dto.id, + isRead: dto.read, + category: dto.title, + timeAgo: formatTimeAgo(dto.createdAt), + message: dto.body, + } +} + +export function useNotificationViewModel() { + const [selectedType, setSelectedType] = useState( + NOTIFICATION_TYPE.GENERAL + ) + const [deletedIds, setDeletedIds] = useState>(new Set()) + const [readIds, setReadIds] = useState>(new Set()) + + const scope = useAuthStore(s => s.scope) + const { + data, + isLoading, + isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useNotifications(scope, selectedType) + + const { mutate: markRead } = useMarkNotificationRead(scope) + + const currentItems: NotificationItemProps[] = useMemo(() => { + const dtos = + data?.pages + .flatMap(page => page.data ?? []) + .filter(dto => !deletedIds.has(dto.id)) ?? [] + + return dtos.map(dto => ({ + ...mapDto(dto), + isRead: dto.read || readIds.has(dto.id), + onDelete: () => setDeletedIds(prev => new Set([...prev, dto.id])), + onClick: () => { + if (!dto.read && !readIds.has(dto.id)) { + setReadIds(prev => new Set([...prev, dto.id])) + markRead(dto.id ?? null) + } + }, + })) + }, [data, deletedIds, readIds, markRead]) + + const markAllRead = () => { + const unreadIds = + data?.pages + .flatMap(page => page.data ?? []) + .filter(dto => !dto.read && !readIds.has(dto.id)) + .map(dto => dto.id) ?? [] + if (unreadIds.length === 0) return + setReadIds(prev => new Set([...prev, ...unreadIds])) + markRead(null) + } + + return { + selectedType, + setSelectedType, + currentItems, + markAllRead, + isLoading, + isError, + fetchNextPage, + hasNextPage: Boolean(hasNextPage), + isFetchingNextPage, + } +} diff --git a/src/pages/notification/index.tsx b/src/pages/notification/index.tsx new file mode 100644 index 0000000..e371f39 --- /dev/null +++ b/src/pages/notification/index.tsx @@ -0,0 +1,192 @@ +import { useEffect, useRef, useState } 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 { + NOTIFICATION_TYPE, + type NotificationType, +} from '@/features/notification/types/notificationType' +import { ROUTES } from '@/shared/constants/routes' +import SettingIcon from '@/assets/icons/settings.svg?react' +import ChevronDownIcon from '@/assets/icons/home/chevron-down.svg?react' + +const TYPE_LABELS: Record = { + GENERAL: '전체', + SCHEDULE: '스케줄', + SUBSTITUTE: '대타', + REPUTATION: '평판', + POSTING_APPLICATION: '공고', + CHAT: '채팅', + WORKSPACE_INVITATION: '매장 초대', + JOIN_REQUEST: '참여 요청', +} + +const ALL_TYPES = Object.values(NOTIFICATION_TYPE) as NotificationType[] + +function NotificationTypeFilter({ + value, + onChange, +}: { + value: NotificationType + onChange: (type: NotificationType) => void +}) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + function handleOutsideClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener('mousedown', handleOutsideClick) + } + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [isOpen]) + + return ( +
+ + + {isOpen && ( +
    + {ALL_TYPES.map(type => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + +export function NotificationPage() { + const navigate = useNavigate() + const { + selectedType, + setSelectedType, + currentItems, + markAllRead, + 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)} + > + + + } + /> + +
+ + +
+
+ +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

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

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

+ 알림이 없습니다. +

+
+ ) : ( + <> +
    + {currentItems.map((item, idx) => ( +
  • + +
  • + ))} +
+
+ {isFetchingNextPage && } +
+ + )} +
+
+ ) +} diff --git a/src/pages/notification/settings/index.tsx b/src/pages/notification/settings/index.tsx new file mode 100644 index 0000000..7ec5d0a --- /dev/null +++ b/src/pages/notification/settings/index.tsx @@ -0,0 +1,99 @@ +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, + nightEnabled, + substituteEnabled, + reputationEnabled, + handleAllChange, + handleNightChange, + setSubstituteEnabled, + setReputationEnabled, + } = useNotificationSettingsViewModel() + + if (isLoading) { + return ( +
+ +
+ +
+
+ ) + } + + return ( +
+ + +
+
+

전체 알림

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

시간 설정

+
+ + 야간 알림 켜기 + + +
+
+ + 방해금지 시간 + + + 23:00 ~ 08:00 + +
+
+
+
+ ) +} diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index cd231a5..235a735 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -46,6 +46,8 @@ export const ROUTES = { PROFILE_SOCIAL: '/my/profile/social', WITHDRAW: '/my/withdraw', }, + NOTIFICATIONS: '/notifications', + NOTIFICATION_SETTINGS: '/notifications/settings', } as const export function managerWorkerSchedulePath( diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 3839da5..46936b0 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -90,4 +90,12 @@ export const queryKeys = { list: (workspaceId: number) => ['fixedWorkerSchedule', 'list', workspaceId] as const, }, + notification: { + list: (scope: 'MANAGER' | 'USER' | null, type?: string) => + ['notifications', scope, type] as const, + consent: (scope: 'MANAGER' | 'USER' | null) => + ['notificationConsent', scope] as const, + unreadCount: (scope: 'MANAGER' | 'USER' | null) => + ['notificationUnreadCount', scope] as const, + }, } as const diff --git a/src/shared/ui/common/Navbar.tsx b/src/shared/ui/common/Navbar.tsx index f305943..85fdfe8 100644 --- a/src/shared/ui/common/Navbar.tsx +++ b/src/shared/ui/common/Navbar.tsx @@ -1,4 +1,6 @@ import { useState, type ReactNode } from 'react' +import { useAuthStore } from '@/shared/stores/useAuthStore' +import { useNotificationUnreadCount } from '@/features/notification/hooks/useNotificationUnreadCount' import { AlterLogo } from '@/shared/ui/common/AlterLogo' import BellIcon from '@/assets/icons/nav/bell.svg' import MenuIcon from '@/assets/icons/nav/menu.svg' @@ -6,6 +8,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' @@ -29,6 +32,8 @@ export function Navbar({ const navigate = useNavigate() const [menuOpen, setMenuOpen] = useState(false) const isMain = variant === 'main' + const scope = useAuthStore(s => s.scope) + const { data: unreadData } = useNotificationUnreadCount(isMain ? scope : null) const handleBackClick = () => { if (onBackClick) { @@ -78,9 +83,13 @@ export function Navbar({ + ) +} diff --git a/src/shared/ui/notification/NotificationItem.tsx b/src/shared/ui/notification/NotificationItem.tsx new file mode 100644 index 0000000..8e4cf4f --- /dev/null +++ b/src/shared/ui/notification/NotificationItem.tsx @@ -0,0 +1,197 @@ +import { useEffect, 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 { + id?: number + 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 offsetRef = useRef(0) + const didDrag = useRef(false) + 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) { + document.removeEventListener('mousemove', moveListenerRef.current) + moveListenerRef.current = null + } + if (upListenerRef.current) { + document.removeEventListener('mouseup', upListenerRef.current) + upListenerRef.current = null + } + endDrag() + } + }, []) + + 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) + } + + const handleDelete = () => { + offsetRef.current = 0 + setOffset(0) + onDelete?.() + } + + 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) => { + e.preventDefault() + startDrag(e.clientX) + + const onMove = (ev: MouseEvent) => moveDrag(ev.clientX) + const onUp = () => { + 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) + } + + 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