Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions src/assets/icon/ic-result-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { Notification } from '@/components/ui/modal/notification';
export { Table } from '@/components/ui/table';
export { Dropdown } from './dropdown';
export { Icon } from './icon';
1 change: 1 addition & 0 deletions src/components/ui/modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Notification } from '@/components/ui/modal/notification';
140 changes: 140 additions & 0 deletions src/components/ui/modal/notification/Notification.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Meta, StoryFn } from '@storybook/nextjs';
import Notification, { Alert } from './Notification';

/* eslint-disable no-console */

const meta: Meta<typeof Notification> = {
title: 'Components/Notification',
component: Notification,
};

export default meta;

const Template: StoryFn<typeof Notification> = args => <Notification {...args} />;

export const Default = Template.bind({});
Default.args = {
alerts: [
{
id: '1',
read: false,
createdAt: '2025-10-03T14:14:00Z',
result: 'accepted',
shop: {
item: {
id: 'shop1',
name: '맛집 A',
category: '음식점',
address1: '서울 강남구',
address2: '역삼동 123-45',
description: '맛있는 음식점',
imageUrl: 'https://via.placeholder.com/150',
originalHourlyPay: 15000,
},
href: '/shop/shop1',
},
notice: {
item: {
id: 'notice1',
hourlyPay: 15000,
description: '맛집 알바',
startsAt: '2025-10-01T09:00:00Z',
workhour: 8,
closed: false,
},
href: '/notice/notice1',
},
},
{
id: '2',
read: false,
createdAt: '2025-10-02T10:50:00Z',
result: 'rejected',
shop: {
item: {
id: 'shop2',
name: '카페 B',
category: '카페',
address1: '서울 서초구',
address2: '서초동 678-90',
description: '커피 맛집',
imageUrl: 'https://via.placeholder.com/150',
originalHourlyPay: 12000,
},
href: '/shop/shop2',
},
notice: {
item: {
id: 'notice2',
hourlyPay: 12000,
description: '카페 알바',
startsAt: '2025-10-02T10:00:00Z',
workhour: 6,
closed: false,
},
href: '/notice/notice2',
},
},
{
id: '3',
read: true,
createdAt: '2025-10-02T08:20:00Z',
result: 'accepted',
shop: {
item: {
id: 'shop3',
name: '도서관 C',
category: '도서관',
address1: '서울 마포구',
address2: '상암동 456-78',
description: '조용한 도서관',
imageUrl: 'https://via.placeholder.com/150',
originalHourlyPay: 10000,
},
href: '/shop/shop3',
},
notice: {
item: {
id: 'notice3',
hourlyPay: 10000,
description: '도서관 알바',
startsAt: '2025-10-03T11:00:00Z',
workhour: 4,
closed: false,
},
href: '/notice/notice3',
},
},
{
id: '4',
read: true,
createdAt: '2025-10-01T11:20:00Z',
result: 'rejected',
shop: {
item: {
id: 'shop4',
name: '헬스장 D',
category: '헬스장',
address1: '서울 송파구',
address2: '잠실동 789-01',
description: '피트니스 센터',
imageUrl: 'https://via.placeholder.com/150',
originalHourlyPay: 18000,
},
href: '/shop/shop4',
},
notice: {
item: {
id: 'notice4',
hourlyPay: 18000,
description: '헬스장 알바',
startsAt: '2025-10-04T09:00:00Z',
workhour: 5,
closed: false,
},
href: '/notice/notice4',
},
},
] as Alert[],
onRead: (id: string) => console.log('Read notification', id),
};
66 changes: 66 additions & 0 deletions src/components/ui/modal/notification/Notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Icon from '@/components/ui/icon/icon';
import { Notice } from '@/types/notice';
import { Shop } from '@/types/shop';
import { useState } from 'react';
import NotificationMessage from './NotificationMessage';

export interface Alert {
id: string;
createdAt: string;
result: 'accepted' | 'rejected';
read: boolean;
shop: { item: Shop; href?: string };
notice: { item: Notice; href?: string };
}

interface NotificationProps {
alerts: Alert[];
onRead: (id: string) => void;
isOpen?: boolean;
onClose?: () => void;
}

export default function Notification({ alerts, onRead }: NotificationProps) {
const [isOpen, setIsOpen] = useState(false);
const notificationCount = alerts.filter(alert => !alert.read).length;
const SORTED_ALERTS = [...alerts].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);

return (
<>
<div className='relative flex justify-end'>
<button
onClick={() => setIsOpen(!isOpen)}
className={`${isOpen ? 'hidden' : 'block'} relative md:block`}
>
<Icon iconName='notificationOn' iconSize='sm' ariaLabel='알림' />
</button>
</div>
{isOpen && (
<div className='flex min-h-screen flex-col gap-4 bg-red-100 px-5 py-10'>
<div className='flex justify-between'>
<div className='text-[20px] font-bold'>알림 {notificationCount}개</div>
<div>
<button onClick={() => setIsOpen(false)}>
<Icon iconName='close' iconSize='lg' ariaLabel='닫기' />
</button>
</div>
</div>
<div></div>
{SORTED_ALERTS.length === 0 ? (
<div className='flex flex-1 items-center justify-center'>
<p>알림이 없습니다.</p>
</div>
) : (
<div className='flex flex-col items-center gap-4'>
{SORTED_ALERTS.map(alert => (
<NotificationMessage key={alert.id} alert={alert} onRead={onRead} />
))}
</div>
)}
</div>
)}
</>
);
}
61 changes: 61 additions & 0 deletions src/components/ui/modal/notification/NotificationMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { getTime } from '@/lib/utils/getTime';
import { timeAgo } from '@/lib/utils/timeAgo';
import { clsx } from 'clsx';
import { Alert } from './Notification';
import ResultBadge from './ResultBadge';

export default function NotificationMessage({
alert,
onRead,
}: {
alert: Alert;
onRead: (id: string) => void;
}) {
const {
id,
result,
read,
createdAt,
shop: {
item: { name: shopName },
},
notice: {
item: { startsAt, workhour },
},
} = alert;

const RESULT_TEXT = result === 'accepted' ? '승인' : '거절';
const DATE_RANGE = getTime(startsAt, workhour);
const NOTIFICATION_MESSAGE_CONTAINER = clsx(
'w-full gap-2 break-words rounded border border-gray-200 bg-white px-3 py-4'
);

return (
<div className={NOTIFICATION_MESSAGE_CONTAINER}>
<button onClick={() => onRead(id)} className='w-full text-left'>
<div className='flex flex-col gap-2'>
<ResultBadge result={result} />
<p
className={clsx('text-sm', {
'text-gray-400': read,
})}
>
{`${shopName} (${DATE_RANGE.date} ${DATE_RANGE.startTime} ~
${DATE_RANGE.endTime}) 공고 지원이 `}
<span
className={clsx({
'text-gray-500': read,
'text-blue-200': !read && result === 'accepted',
'text-red-400': !read && result === 'rejected',
})}
>
{RESULT_TEXT}
</span>
되었어요.
</p>
<span className='text-xs text-gray-400'>{timeAgo(createdAt)}</span>
</div>
</button>
</div>
);
}
19 changes: 19 additions & 0 deletions src/components/ui/modal/notification/ResultBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Icon from '@/components/ui/icon/icon';

export interface ResultBadgeProps {
result: 'accepted' | 'rejected';
}
const ICON_COLORS: Record<ResultBadgeProps['result'], string> = {
accepted: 'bg-blue-200',
rejected: 'bg-red-400',
};

export default function ResultBadge({ result }: ResultBadgeProps) {
return (
<Icon
iconName='resultBadge'
className={`${ICON_COLORS[result]} h-[5px] w-[5px]`}
ariaLabel={result === 'accepted' ? '승인 상태' : '거절 상태'}
/>
);
}
1 change: 1 addition & 0 deletions src/components/ui/modal/notification/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Notification } from '@/components/ui/modal/notification/Notification';
5 changes: 3 additions & 2 deletions src/components/ui/table/Table.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Table from '@/components/ui/table/Table';
import { TableRowProps } from '@/components/ui/table/TableRowProps';
import { UserType } from '@/types/user';
import { Meta, StoryObj } from '@storybook/nextjs';
import { useEffect, useState } from 'react';
import { fetchTableData, UserType } from './testApi';
import { fetchTableData } from './testApi';

const meta: Meta<typeof Table> = {
title: 'UI/Table',
Expand Down Expand Up @@ -39,5 +40,5 @@ export const TableExample: Story = {
args: {
userType: 'employer',
},
render: args => <TableWithTestApi userType={args.userType} />,
render: args => <TableWithTestApi userType={args.userType as UserType} />,
};
3 changes: 2 additions & 1 deletion src/components/ui/table/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import TableRow from '@/components/ui/table/TableRow';
import { TableRowProps } from '@/components/ui/table/TableRowProps';
import { UserType } from '@/types/user';

interface TableProps {
data: TableRowProps[];
headers: string[];
userType: 'employer' | 'employee';
userType: UserType;
}

// <Table headers={headers} data={data} userType={type} /> type은 확인이 좀 더 필요합니다
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/table/TableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ const TD_BASE = 'border-b px-2 py3';
const TD_STATUS = 'border-b px-2 py-[9px]';

export default function TableRow({ rowData, variant }: TableTypeVariant) {
const { STRAT, END, duration } = getTime(rowData.startsAt, rowData.workhour);
const { date, startTime, endTime, duration } = getTime(rowData.startsAt, rowData.workhour);

return (
<tr className='text-left'>
<td className={TD_BASE}>{rowData.name}</td>
{variant === 'employee' ? (
<>
<td className={TD_BASE}>{`${STRAT} ~ ${END} (${duration})`}</td>
<td className={TD_BASE}>{`${date} ${startTime} ~ ${date} ${endTime}} (${duration})`}</td>
<td className={TD_BASE}>{rowData.hourlyPay}</td>
</>
) : (
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/table/testApi.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type UserType = 'employer' | 'employee';
import type { UserType } from '@/types/user';

export const fetchTableData = async (userType: UserType) => {
return new Promise<{ headers: string[]; data: unknown[] }>(resolve => {
Expand Down
2 changes: 2 additions & 0 deletions src/constants/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import notificationOn from '@/assets/icon/ic-notification-on.svg';
import phone from '@/assets/icon/ic-phone.svg';
import radioOff from '@/assets/icon/ic-radio-off.svg';
import radioOn from '@/assets/icon/ic-radio-on.svg';
import resultBadge from '@/assets/icon/ic-result-badge.svg';
import search from '@/assets/icon/ic-search.svg';
import successCircle from '@/assets/icon/ic-success-circle.svg';
import success from '@/assets/icon/ic-success.svg';
import warningCircle from '@/assets/icon/ic-warning-circle.svg';
import warning from '@/assets/icon/ic-warning.svg';

export const ICONS = {
resultBadge: resultBadge.src,
arrowUp: arrowUp.src,
calendar: calendar.src,
camera: camera.src,
Expand Down
Loading