('day');
+
+ const TODAY = new Date();
+ TODAY.setHours(0, 0, 0, 0);
+
+ const handleSelect = (date: Date) => {
+ const selectedDate = new Date(date);
+ selectedDate.setHours(0, 0, 0, 0);
+
+ if (selectedDate < TODAY) {
+ return;
+ }
+ setCurrentDay(date);
+ setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1));
+ onSelect?.(date);
+ };
+
+ // day 한 달 단위, year 10년 단위, month 1년 단위
+ const onChange = (offset: number) => {
+ if (selectMode === 'day') {
+ setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + offset, 1));
+ } else if (selectMode === 'year') {
+ setCurrentMonth(
+ new Date(currentMonth.getFullYear() + offset * 10, currentMonth.getMonth(), 1)
+ );
+ } else {
+ setCurrentMonth(new Date(currentMonth.getFullYear() + offset, currentMonth.getMonth(), 1));
+ }
+ };
+
+ // 오늘로 이동
+ const handleToday = () => {
+ setCurrentMonth(TODAY);
+ setCurrentDay(TODAY);
+ setSelectMode('day');
+ onSelect?.(TODAY);
+ };
+
+ // 모드 전환
+ const onToggleMode = () => {
+ setSelectMode(prev => (prev === 'day' ? 'month' : prev === 'month' ? 'year' : 'day'));
+ };
+
+ return (
+
+
+
+ {selectMode === 'day' && (
+
+ )}
+
+ {selectMode === 'month' && (
+
{
+ setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
+ setSelectMode('day');
+ }}
+ />
+ )}
+
+ {selectMode === 'year' && (
+ {
+ setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
+ setSelectMode('month');
+ }}
+ />
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/ui/calendar/CalendarHeader.tsx b/src/components/ui/calendar/CalendarHeader.tsx
new file mode 100644
index 0000000..782234e
--- /dev/null
+++ b/src/components/ui/calendar/CalendarHeader.tsx
@@ -0,0 +1,48 @@
+import { Icon } from '@/components/ui/icon';
+import { CalendarHeaderProps } from '@/types/calendar';
+import { clsx } from 'clsx';
+import { useMemo } from 'react';
+
+export default function CalendarHeader({
+ selectMode,
+ currentMonth,
+ onToggleMode,
+ onChange,
+}: CalendarHeaderProps) {
+ const CALENDAR_ARROW_CLASS = clsx('rounded px-1 pt-1 hover:bg-gray-100');
+
+ const headerLabel = useMemo(() => {
+ switch (selectMode) {
+ case 'day':
+ return currentMonth.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: 'long',
+ });
+ case 'month':
+ return `${currentMonth.getFullYear()}년`;
+ case 'year': {
+ const startYear = Math.floor(currentMonth.getFullYear() / 10) * 10;
+ const endYear = startYear + 9;
+ return `${startYear} - ${endYear}`;
+ }
+ default:
+ return ' ';
+ }
+ }, [selectMode, currentMonth]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/calendar/DayViewMode.tsx b/src/components/ui/calendar/DayViewMode.tsx
new file mode 100644
index 0000000..547673e
--- /dev/null
+++ b/src/components/ui/calendar/DayViewMode.tsx
@@ -0,0 +1,68 @@
+import { cn } from '@/lib/utils/cn';
+import { fillCalendarDays } from '@/lib/utils/fillCalendarDays';
+import { DayViewProps } from '@/types/calendar';
+import { clsx } from 'clsx';
+
+export default function DayViewMode({ currentMonth, currentDay, onSelect }: DayViewProps) {
+ const DAYS = fillCalendarDays(currentMonth.getFullYear(), currentMonth.getMonth());
+ const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
+
+ const TODAY = new Date();
+ TODAY.setHours(0, 0, 0, 0);
+
+ const DAY_CALENDAR_CLASS = clsx('text-md grid grid-cols-7 text-center');
+
+ return (
+ <>
+
+ {WEEKDAYS.map((day, i) => {
+ const headerClass = i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-200' : '';
+
+ return (
+
+ {day}
+
+ );
+ })}
+
+
+
+ {DAYS.map((dayObj, i) => {
+ const { date, isCurrentMonth } = dayObj;
+
+ const isDisabled = date < TODAY;
+ const isSelected = date.toDateString() === currentDay.toDateString();
+ const dayOfWeek = date.getDay();
+
+ const DAY_CELL_CLASS = isDisabled
+ ? 'text-gray-500'
+ : !isSelected && isCurrentMonth && dayOfWeek === 0
+ ? 'text-red-400'
+ : !isSelected && isCurrentMonth && dayOfWeek === 6
+ ? 'text-blue-200'
+ : '';
+
+ return (
+
+ );
+ })}
+
+ >
+ );
+}
diff --git a/src/components/ui/calendar/MonthViewMode.tsx b/src/components/ui/calendar/MonthViewMode.tsx
new file mode 100644
index 0000000..6c0410c
--- /dev/null
+++ b/src/components/ui/calendar/MonthViewMode.tsx
@@ -0,0 +1,17 @@
+import { MonthViewProps } from '@/types/calendar';
+
+export default function MonthViewMode({ onSelect: onSelectMonth }: MonthViewProps) {
+ return (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/ui/calendar/TimeSelector.styles.tsx b/src/components/ui/calendar/TimeSelector.styles.tsx
new file mode 100644
index 0000000..7ccb151
--- /dev/null
+++ b/src/components/ui/calendar/TimeSelector.styles.tsx
@@ -0,0 +1,8 @@
+import React from 'react';
+
+const TIME_SCROLL_CLASS =
+ 'flex max-h-[88px] flex-col gap-4 overflow-y-auto rounded border p-2 text-xl';
+
+export const ScrollList: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+);
diff --git a/src/components/ui/calendar/TimeSelector.tsx b/src/components/ui/calendar/TimeSelector.tsx
new file mode 100644
index 0000000..0e59f37
--- /dev/null
+++ b/src/components/ui/calendar/TimeSelector.tsx
@@ -0,0 +1,96 @@
+import { cn } from '@/lib/utils/cn';
+import { Period, TimeSelectorProps } from '@/types/calendar';
+import { useState } from 'react';
+import { ScrollList } from './TimeSelector.styles';
+
+export default function TimeSelector({ onSelect, period, hours, minutes }: TimeSelectorProps) {
+ const [currentPeriod, setCurrentPeriod] = useState(period);
+ const [currentHour, setCurrentHour] = useState(hours);
+ const [currentMinute, setCurrentMinute] = useState(minutes);
+
+ // 01 ~ 12
+ const hoursList = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'));
+ // 00 ~ 59
+ const minutesList = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
+
+ const notifySelect = (p: Period, h: string, m: string) => {
+ onSelect?.(`${p} ${h}:${m}`);
+ };
+
+ const handleSelect = (type: 'period' | 'hour' | 'minute', value: string) => {
+ const updates = {
+ period: () => setCurrentPeriod(value as Period),
+ hour: () => setCurrentHour(value),
+ minute: () => setCurrentMinute(value),
+ };
+
+ updates[type]();
+
+ notifySelect(
+ type === 'period' ? (value as Period) : currentPeriod,
+ type === 'hour' ? value : currentHour,
+ type === 'minute' ? value : currentMinute
+ );
+ };
+
+ const TIME_SELECTOR_WRAPPER_CLASS =
+ 'mt-3 flex items-center justify-center gap-6 rounded-lg border bg-white p-3';
+
+ const BASE_PERIOD_CLASS = 'rounded-lg px-4 py-2 font-semibold transition';
+ const BASE_TIME_CLASS = 'rounded px-3 py-1 transition';
+
+ const selectPeriodClass = (p: Period) =>
+ cn(
+ BASE_PERIOD_CLASS,
+ currentPeriod === p ? 'bg-blue-200 text-white' : 'bg-gray-100 hover:bg-gray-200'
+ );
+
+ const selectTimeClass = (value: string, currentValue: string) =>
+ cn(BASE_TIME_CLASS, currentValue === value ? 'bg-blue-200 text-white' : 'hover:bg-gray-100');
+
+ return (
+
+
+ {['오전', '오후'].map(p => (
+
+ ))}
+
+
+
+
+
+ {hoursList.map(h => (
+
+ ))}
+
+
+
+
+
+ {minutesList.map(m => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/ui/calendar/YearViewMode.tsx b/src/components/ui/calendar/YearViewMode.tsx
new file mode 100644
index 0000000..c321bd6
--- /dev/null
+++ b/src/components/ui/calendar/YearViewMode.tsx
@@ -0,0 +1,22 @@
+import { YearViewProps } from '@/types/calendar';
+
+export default function YearViewMode({ currentMonth, onSelect }: YearViewProps) {
+ const START_YEAR = Math.floor(currentMonth.getFullYear() / 10) * 10;
+
+ return (
+
+ {Array.from({ length: 10 }).map((_, i) => {
+ const YEAR = START_YEAR + i;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/ui/calendar/index.ts b/src/components/ui/calendar/index.ts
new file mode 100644
index 0000000..9bc02fa
--- /dev/null
+++ b/src/components/ui/calendar/index.ts
@@ -0,0 +1,6 @@
+export { default as Calendar } from '@/components/ui/calendar/Calendar';
+export { default as CalendarHeader } from '@/components/ui/calendar/CalendarHeader';
+export { default as DayViewMode } from '@/components/ui/calendar/DayViewMode';
+export { default as MonthViewMode } from '@/components/ui/calendar/MonthViewMode';
+export { default as TimeSelector } from '@/components/ui/calendar/TimeSelector';
+export { default as YearViewMode } from '@/components/ui/calendar/YearViewMode';
diff --git a/src/components/ui/card/card.styles.ts b/src/components/ui/card/card.styles.ts
new file mode 100644
index 0000000..1e227bc
--- /dev/null
+++ b/src/components/ui/card/card.styles.ts
@@ -0,0 +1,85 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+
+const cardFrame = cva('rounded-xl border border-gray-200 bg-white');
+
+const cardImageWrapper = cva('relative rounded-xl overflow-hidden');
+
+const cardHeading = cva('font-bold', {
+ variants: {
+ size: {
+ sm: 'text-heading-s',
+ md: 'text-heading-m',
+ lg: 'text-heading-l',
+ },
+ status: {
+ open: 'text-black',
+ inactive: 'text-gray-300',
+ },
+ },
+ defaultVariants: { size: 'md', status: 'open' },
+});
+
+const cardInfoLayout = cva('flex flex-nowrap items-center tablet:items-center gap-1.5');
+
+const cardInfoText = cva('text-caption tablet:text-body-s', {
+ variants: {
+ status: {
+ open: 'text-gray-500',
+ inactive: 'text-gray-300',
+ },
+ },
+ defaultVariants: { status: 'open' },
+});
+
+const cardInfoIcon = cva('shrink-0', {
+ variants: {
+ status: {
+ open: 'bg-gray-700',
+ inactive: 'bg-gray-300',
+ },
+ },
+ defaultVariants: { status: 'open' },
+});
+
+const cardPayLayout = cva('flex items-center gap-x-3');
+
+const cardPayText = cva('font-bold text-modal tracking-wide', {
+ variants: {
+ status: {
+ open: 'text-black',
+ inactive: 'text-gray-300',
+ },
+ },
+ defaultVariants: { status: 'open' },
+});
+const cardBadge = cva(
+ 'flex items-center gap-x-0.5 rounded-full px-2 py-1 tablet:py-2 tablet:px-3',
+ {
+ variants: {
+ status: {
+ post: 'absolute bottom-3 right-3 z-[1] border border-white',
+ notice: '',
+ },
+ },
+ defaultVariants: { status: 'notice' },
+ }
+);
+
+const cardBadgeText = cva('whitespace-nowrap text-caption text-white font-bold tablet:text-body-s');
+
+const cardBadgeIcon = cva('self-start bg-white');
+
+export const cardLayout = {
+ frame: cardFrame,
+ imageWrapper: cardImageWrapper,
+ heading: cardHeading,
+ infoLayout: cardInfoLayout,
+ info: cardInfoText,
+ infoIcon: cardInfoIcon,
+ payLayout: cardPayLayout,
+ payText: cardPayText,
+ badge: cardBadge,
+ badgeText: cardBadgeText,
+ badgeIcon: cardBadgeIcon,
+};
+export type CardStatusVariant = VariantProps['status'];
diff --git a/src/components/ui/card/cardBadge.tsx b/src/components/ui/card/cardBadge.tsx
new file mode 100644
index 0000000..35ccc87
--- /dev/null
+++ b/src/components/ui/card/cardBadge.tsx
@@ -0,0 +1,44 @@
+import { Icon } from '@/components/ui/icon';
+import { calcPayIncreasePercent } from '@/lib/utils/calcPayIncrease';
+import { cn } from '@/lib/utils/cn';
+import { type CardVariant } from '@/types/notice';
+import { cardLayout } from './card.styles';
+interface CardBadgeProps {
+ variant: CardVariant;
+ hourlyPay?: number;
+ originalHourlyPay?: number;
+}
+const CardBadge = ({ variant, hourlyPay, originalHourlyPay }: CardBadgeProps) => {
+ if (!hourlyPay || !originalHourlyPay) return;
+
+ const payIncreasePercent = calcPayIncreasePercent(hourlyPay, originalHourlyPay);
+ const payIncreaseLabel =
+ payIncreasePercent && (payIncreasePercent > 100 ? '100% 이상' : `${payIncreasePercent}%`);
+ const badgeColorClass =
+ payIncreasePercent == null
+ ? ''
+ : payIncreasePercent >= 50
+ ? 'bg-red-500'
+ : payIncreasePercent >= 30
+ ? 'bg-red-300'
+ : 'bg-red-200';
+
+ return (
+ <>
+ {payIncreasePercent !== null && (
+
+ 기존 시급 {payIncreaseLabel}
+
+
+ )}
+ >
+ );
+};
+
+export default CardBadge;
diff --git a/src/components/ui/card/cardImage.tsx b/src/components/ui/card/cardImage.tsx
new file mode 100644
index 0000000..d917242
--- /dev/null
+++ b/src/components/ui/card/cardImage.tsx
@@ -0,0 +1,59 @@
+import { cn } from '@/lib/utils/cn';
+import { type CardVariant } from '@/types/notice';
+import Image from 'next/image';
+import { ReactNode, useEffect, useState } from 'react';
+import { cardLayout } from './card.styles';
+
+interface CardImageProps {
+ variant: CardVariant;
+ src?: string;
+ alt?: string;
+ className?: string;
+ children?: ReactNode;
+}
+const FALLBACK_SRC = '/fallback.png';
+const CardImage = ({ variant, src, alt, className, children }: CardImageProps) => {
+ const [imgSrc, setImgSrc] = useState(src ?? FALLBACK_SRC);
+
+ // src가 비동기로 들어오거나 변경될 때 state를 동기화
+ useEffect(() => {
+ setImgSrc(src ?? FALLBACK_SRC);
+ }, [src]);
+
+ const handleError = () => {
+ setImgSrc(FALLBACK_SRC);
+ };
+
+ const isValidSrc =
+ typeof imgSrc === 'string' &&
+ (imgSrc.startsWith('https://bootcamp-project-api.s3.ap-northeast-2.amazonaws.com') ||
+ imgSrc.startsWith('https://picsum.photos'));
+
+ return (
+
+
+ {children}
+
+ );
+};
+export default CardImage;
diff --git a/src/components/ui/card/cardInfo.tsx b/src/components/ui/card/cardInfo.tsx
new file mode 100644
index 0000000..4e89eea
--- /dev/null
+++ b/src/components/ui/card/cardInfo.tsx
@@ -0,0 +1,27 @@
+import { Icon } from '@/components/ui/icon';
+import { IconName } from '@/constants/icon';
+import { ReactNode } from 'react';
+import { cardLayout, CardStatusVariant } from './card.styles';
+
+interface CardInfo {
+ iconName: IconName;
+ status?: CardStatusVariant;
+ ariaLabel: string;
+ children?: ReactNode;
+}
+
+const CardInfo = ({ iconName, ariaLabel, status, children }: CardInfo) => {
+ return (
+
+
+ {children}
+
+ );
+};
+
+export default CardInfo;
diff --git a/src/components/ui/card/index.ts b/src/components/ui/card/index.ts
new file mode 100644
index 0000000..4e9fcac
--- /dev/null
+++ b/src/components/ui/card/index.ts
@@ -0,0 +1,2 @@
+export { default as Notice } from '@/components/ui/card/notice/notice';
+export { default as Post } from '@/components/ui/card/post/post';
diff --git a/src/components/ui/card/notice/components/noticeHeader.tsx b/src/components/ui/card/notice/components/noticeHeader.tsx
new file mode 100644
index 0000000..bad17c3
--- /dev/null
+++ b/src/components/ui/card/notice/components/noticeHeader.tsx
@@ -0,0 +1,15 @@
+import { cardLayout } from '@/components/ui/card/card.styles';
+import { noticeLabel } from '@/components/ui/card/notice/notice.styles';
+
+interface NoticeHeaderProps {
+ name?: string;
+ category?: string;
+ className?: string;
+}
+const NoticeHeader = ({ name, category, className }: NoticeHeaderProps) => (
+
+);
+export default NoticeHeader;
diff --git a/src/components/ui/card/notice/components/noticeInfo.tsx b/src/components/ui/card/notice/components/noticeInfo.tsx
new file mode 100644
index 0000000..dbe2f3a
--- /dev/null
+++ b/src/components/ui/card/notice/components/noticeInfo.tsx
@@ -0,0 +1,76 @@
+import { cardLayout } from '@/components/ui/card/card.styles';
+import CardBadge from '@/components/ui/card/cardBadge';
+import CardInfo from '@/components/ui/card/cardInfo';
+import {
+ noticeButton,
+ noticeInfoWrapper,
+ noticeLabel,
+} from '@/components/ui/card/notice/notice.styles';
+import { getTime } from '@/lib/utils/dateFormatter';
+import { formatNumber } from '@/lib/utils/formatNumber';
+import { type NoticeCard, type NoticeVariant } from '@/types/notice';
+import { ReactNode } from 'react';
+import NoticeHeader from './noticeHeader';
+
+interface NoticeInfoProps> {
+ value: T;
+ variant: NoticeVariant;
+ buttonComponent: ReactNode;
+}
+
+const NoticeInfo = >({
+ value,
+ variant,
+ buttonComponent,
+}: NoticeInfoProps) => {
+ const {
+ name,
+ category,
+ hourlyPay,
+ originalHourlyPay,
+ startsAt,
+ workhour,
+ address1,
+ shopDescription,
+ } = value;
+
+ const { date, startTime, endTime } = getTime(startsAt ?? '', workhour ?? 0);
+
+ return (
+
+ );
+};
+export default NoticeInfo;
diff --git a/src/components/ui/card/notice/components/renderNotice.tsx b/src/components/ui/card/notice/components/renderNotice.tsx
new file mode 100644
index 0000000..218b1c3
--- /dev/null
+++ b/src/components/ui/card/notice/components/renderNotice.tsx
@@ -0,0 +1,39 @@
+import CardImage from '@/components/ui/card/cardImage';
+import { descriptionWrapper, noticeFrame } from '@/components/ui/card/notice/notice.styles';
+import { type NoticeCard } from '@/types/notice';
+import { ReactNode } from 'react';
+import NoticeHeader from './noticeHeader';
+import NoticeInfo from './noticeInfo';
+
+interface RenderNoticeProps> {
+ items: {
+ name?: string;
+ category?: string;
+ imageUrl?: string;
+ description?: string;
+ variant: 'notice';
+ value: T;
+ };
+ buttonComponent: ReactNode;
+}
+
+const RenderNotice = >({
+ items,
+ buttonComponent,
+}: RenderNoticeProps) => {
+ const { name, imageUrl, category, description, variant, value } = items;
+ return (
+ <>
+
+
+
+ 공고 설명
+ {description}
+
+ >
+ );
+};
+export default RenderNotice;
diff --git a/src/components/ui/card/notice/components/renderShop.tsx b/src/components/ui/card/notice/components/renderShop.tsx
new file mode 100644
index 0000000..3435770
--- /dev/null
+++ b/src/components/ui/card/notice/components/renderShop.tsx
@@ -0,0 +1,35 @@
+import { cardLayout } from '@/components/ui/card/card.styles';
+import CardImage from '@/components/ui/card/cardImage';
+import { noticeFrame } from '@/components/ui/card/notice/notice.styles';
+import { cn } from '@/lib/utils/cn';
+import { NoticeShopCard } from '@/types/shop';
+import { ReactNode } from 'react';
+import NoticeInfo from './noticeInfo';
+
+type ShopCard = Omit;
+
+interface RenderShopItems {
+ name?: string;
+ imageUrl?: string;
+ variant: 'shop';
+ value: ShopCard;
+}
+
+interface RenderShopProps {
+ items: RenderShopItems;
+ buttonComponent: ReactNode;
+}
+
+const RenderShop = ({ items, buttonComponent }: RenderShopProps) => {
+ const { name, imageUrl, variant, value } = items;
+ return (
+ <>
+ 내 가게
+
+ >
+ );
+};
+export default RenderShop;
diff --git a/src/components/ui/card/notice/mockData/mockData.json b/src/components/ui/card/notice/mockData/mockData.json
new file mode 100644
index 0000000..3803363
--- /dev/null
+++ b/src/components/ui/card/notice/mockData/mockData.json
@@ -0,0 +1,76 @@
+{
+ "item": {
+ "id": "notice-001",
+ "hourlyPay": 20000,
+ "startsAt": "2025-10-11T11:00:00Z",
+ "workhour": 4,
+ "description": "주말 점심 시간대 근무자를 모집합니다.",
+ "closed": false,
+ "shop": {
+ "item": {
+ "id": "shop-bridge",
+ "name": "여의도 베이커리 카페",
+ "category": "카페",
+ "address1": "서울시 영등포구",
+ "address2": "여의도동 2가 123-45",
+ "description": "여의도 한강 뷰를 즐길 수 있는 베이커리 카페! 직장인이 많은 곳이라 평일 점심에만 많이바쁘고 그 외는 한가한 편입니다.",
+ "imageUrl": "https://picsum.photos/id/16/640/360",
+ "originalHourlyPay": 18000
+ },
+ "href": "/shops/shop-bridge"
+ },
+ "currentUserApplication": null
+ },
+ "links": [
+ {
+ "rel": "self",
+ "description": "공고 정보",
+ "method": "GET",
+ "href": "/shops/shop-bridge/notices/notice-001"
+ },
+ {
+ "rel": "update",
+ "description": "공고 수정",
+ "method": "PUT",
+ "href": "/shops/shop-bridge/notices/notice-001",
+ "body": {
+ "hourlyPay": "number",
+ "startsAt": "string",
+ "workhour": "string",
+ "description": "string"
+ }
+ },
+ {
+ "rel": "applications",
+ "description": "지원 목록",
+ "method": "GET",
+ "href": "/shops/shop-bridge/notices/notice-001/applications",
+ "query": {
+ "offset": "undefined | number",
+ "limit": "undefined | number"
+ }
+ },
+ {
+ "rel": "create",
+ "description": "지원하기",
+ "method": "POST",
+ "href": "/shops/shop-bridge/notices/notice-001/applications"
+ },
+ {
+ "rel": "shop",
+ "description": "가게 정보",
+ "method": "GET",
+ "href": "/shops/shop-bridge"
+ },
+ {
+ "rel": "list",
+ "description": "공고 목록",
+ "method": "GET",
+ "href": "/shops/shop-bridge/notices",
+ "query": {
+ "offset": "undefined | number",
+ "limit": "undefined | number"
+ }
+ }
+ ]
+}
diff --git a/src/components/ui/card/notice/mockData/noticeWrapper.tsx b/src/components/ui/card/notice/mockData/noticeWrapper.tsx
new file mode 100644
index 0000000..e1cb3b8
--- /dev/null
+++ b/src/components/ui/card/notice/mockData/noticeWrapper.tsx
@@ -0,0 +1,76 @@
+import { Button } from '@/components/ui/button';
+import Notice from '@/components/ui/card/notice/notice';
+import { getNoticeStatus } from '@/lib/utils/getNoticeStatus';
+import { type NoticeCard } from '@/types/notice';
+import { NoticeShopCard } from '@/types/shop';
+import Link from 'next/link';
+import mockResponse from './mockData.json';
+import shopMockResponse from './shopMockData.json';
+
+// mockData
+type RawNotice = typeof mockResponse;
+type RawShopNotice = typeof shopMockResponse;
+
+const toNoticeCard = ({ item }: RawNotice): NoticeCard => {
+ const shop = item.shop.item;
+
+ return {
+ id: item.id,
+ hourlyPay: item.hourlyPay,
+ startsAt: item.startsAt,
+ workhour: item.workhour,
+ description: item.description,
+ closed: item.closed,
+ shopId: shop.id,
+ name: shop.name,
+ category: shop.category,
+ address1: shop.address1,
+ shopDescription: shop.description,
+ imageUrl: shop.imageUrl,
+ originalHourlyPay: shop.originalHourlyPay,
+ };
+};
+const toShopCard = ({ item }: RawShopNotice): NoticeShopCard => {
+ const shop = item;
+
+ return {
+ shopId: shop.id,
+ name: shop.name,
+ category: shop.category,
+ address1: shop.address1,
+ shopDescription: shop.description,
+ imageUrl: shop.imageUrl,
+ };
+};
+
+const NoticeWrapper = () => {
+ // notice
+ const notice: NoticeCard = toNoticeCard(mockResponse);
+ const status = getNoticeStatus(notice.closed, notice.startsAt);
+ const href = `/shops/${notice.shopId}/notices/${notice.id}`;
+ // shop
+ const shopItem: NoticeShopCard = toShopCard(shopMockResponse);
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+
+export default NoticeWrapper;
diff --git a/src/components/ui/card/notice/mockData/shopMockData.json b/src/components/ui/card/notice/mockData/shopMockData.json
new file mode 100644
index 0000000..95e2909
--- /dev/null
+++ b/src/components/ui/card/notice/mockData/shopMockData.json
@@ -0,0 +1,59 @@
+{
+ "item": {
+ "id": "4490151c-5217-4157-b072-9c37b05bed47",
+ "name": "진주회관",
+ "category": "한식",
+ "address1": "서울시 중구",
+ "address2": "세종대로11길 26",
+ "description": "콩국수 맛집 인정따리",
+ "imageUrl": "https://bootcamp-project-api.s3.ap-northeast-2.amazonaws.com/0-1/the-julge/1bdb43c8-ff08-4a46-81b0-7f91efced98c-jinju4.png",
+ "originalHourlyPay": 10000,
+ "user": {
+ "item": {
+ "id": "4e560aa8-ae5a-40e1-a6e0-2a1e8b866d17",
+ "email": "test-employer1@codeit.com",
+ "type": "employer"
+ },
+ "href": "/users/4e560aa8-ae5a-40e1-a6e0-2a1e8b866d17"
+ }
+ },
+ "links": [
+ {
+ "rel": "self",
+ "description": "가게 정보",
+ "method": "GET",
+ "href": "/shops/4490151c-5217-4157-b072-9c37b05bed47"
+ },
+ {
+ "rel": "update",
+ "description": "가게 정보 수정",
+ "method": "PUT",
+ "href": "/shops/4490151c-5217-4157-b072-9c37b05bed47",
+ "body": {
+ "name": "string",
+ "category": "한식 | 중식 | 일식 | 양식 | 분식 | 카페 | 편의점 | 기타",
+ "address1": "서울시 종로구 | 서울시 중구 | 서울시 용산구 | 서울시 성동구 | 서울시 광진구 | 서울시 동대문구 | 서울시 중랑구 | 서울시 성북구 | 서울시 강북구 | 서울시 도봉구 | 서울시 노원구 | 서울시 은평구 | 서울시 서대문구 | 서울시 마포구 | 서울시 양천구 | 서울시 강서구 | 서울시 구로구 | 서울시 금천구 | 서울시 영등포구 | 서울시 동작구 | 서울시 관악구 | 서울시 서초구 | 서울시 강남구 | 서울시 송파구 | 서울시 강동구",
+ "address2": "string",
+ "description": "string",
+ "imageUrl": "string",
+ "originalHourlyPay": "number"
+ }
+ },
+ {
+ "rel": "user",
+ "description": "가게 주인 정보",
+ "method": "GET",
+ "href": "/users/4e560aa8-ae5a-40e1-a6e0-2a1e8b866d17"
+ },
+ {
+ "rel": "notices",
+ "description": "공고 목록",
+ "method": "GET",
+ "href": "/shops/4490151c-5217-4157-b072-9c37b05bed47/notices",
+ "query": {
+ "offset": "undefined | number",
+ "limit": "undefined | number"
+ }
+ }
+ ]
+}
diff --git a/src/components/ui/card/notice/notice.stories.tsx b/src/components/ui/card/notice/notice.stories.tsx
new file mode 100644
index 0000000..102362d
--- /dev/null
+++ b/src/components/ui/card/notice/notice.stories.tsx
@@ -0,0 +1,61 @@
+import type { NoticeCard } from '@/types/notice';
+import type { Meta, StoryObj } from '@storybook/react';
+import Notice from './notice';
+
+const oneDayMs = 24 * 60 * 60 * 1000;
+
+const baseNotice: NoticeCard = {
+ id: 'notice-001',
+ hourlyPay: 20000,
+ startsAt: new Date(Date.now() + oneDayMs).toISOString(),
+ workhour: 4,
+ description: '주말 점심 시간대 근무자를 모집합니다.',
+ closed: false,
+ shopId: 'shop-bridge',
+ name: '한강 브런치 카페',
+ category: '카페',
+ address1: '서울시 용산구',
+ shopDescription: '한강 뷰를 자랑하는 브런치 카페',
+ imageUrl: 'https://picsum.photos/id/1080/640/360',
+ originalHourlyPay: 18000,
+};
+
+const meta = {
+ title: 'UI/Notice',
+ component: Notice,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ notice: baseNotice,
+ variant: 'notice',
+ children: ,
+ },
+};
+
+export const ShopVariant: Story = {
+ args: {
+ notice: {
+ ...baseNotice,
+ name: '내 가게 한강 브런치',
+ category: '브런치',
+ },
+ variant: 'shop',
+ children: ,
+ },
+};
diff --git a/src/components/ui/card/notice/notice.styles.ts b/src/components/ui/card/notice/notice.styles.ts
new file mode 100644
index 0000000..e03afdb
--- /dev/null
+++ b/src/components/ui/card/notice/notice.styles.ts
@@ -0,0 +1,20 @@
+import { cardLayout } from '@/components/ui/card/card.styles';
+import { cn } from '@/lib/utils/cn';
+import { cva } from 'class-variance-authority';
+
+export const noticeWrapper = cva('flex flex-col gap-4 tablet:gap-6');
+
+export const noticeFrame = cva(
+ cn(
+ cardLayout.frame(),
+ 'flex flex-col gap-3 p-5 tablet:gap-4 tablet:p-6 desktop:flex-row desktop:gap-8'
+ )
+);
+
+export const noticeInfoWrapper = cva('shrink-0 desktop:w-[346px]');
+
+export const noticeLabel = cva('pb-2 text-body-m font-bold text-red-400');
+
+export const descriptionWrapper = cva('flex flex-col gap-3 rounded-xl bg-gray-100 p-4 tablet:p-8');
+
+export const noticeButton = cva('mt-6 tablet:mt-10 desktop:mt-8');
diff --git a/src/components/ui/card/notice/notice.tsx b/src/components/ui/card/notice/notice.tsx
new file mode 100644
index 0000000..30dacbf
--- /dev/null
+++ b/src/components/ui/card/notice/notice.tsx
@@ -0,0 +1,78 @@
+import { Container } from '@/components/layout/container';
+import { cn } from '@/lib/utils/cn';
+import { type NoticeCard, type NoticeVariant } from '@/types/notice';
+import { ReactNode } from 'react';
+import RenderNotice from './components/renderNotice';
+import RenderShop from './components/renderShop';
+import { noticeWrapper } from './notice.styles';
+
+interface NoticeProps> {
+ notice: T;
+ variant?: NoticeVariant;
+ children?: ReactNode;
+ className?: string;
+}
+
+const Notice = >({
+ notice,
+ variant = 'notice',
+ children,
+ className,
+}: NoticeProps) => {
+ const {
+ hourlyPay,
+ startsAt,
+ workhour,
+ description,
+ name,
+ category,
+ address1,
+ shopDescription,
+ imageUrl,
+ originalHourlyPay,
+ } = notice;
+
+ const noticeValue = {
+ hourlyPay: hourlyPay ?? 0,
+ originalHourlyPay: originalHourlyPay ?? 0,
+ startsAt: startsAt ?? '',
+ workhour: workhour ?? 0,
+ address1: address1 ?? '',
+ shopDescription: shopDescription ?? '',
+ };
+
+ const noticeItem = {
+ name,
+ category,
+ imageUrl,
+ description,
+ variant: 'notice' as const,
+ value: noticeValue,
+ };
+
+ const shopValue = {
+ name: name ?? '',
+ category: category ?? '',
+ address1: address1 ?? '',
+ imageUrl: imageUrl ?? '',
+ shopDescription: shopDescription ?? '',
+ };
+
+ const shopItem = {
+ name,
+ imageUrl,
+ variant: 'shop' as const,
+ value: shopValue,
+ };
+
+ return (
+
+ {variant === 'notice' ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+export default Notice;
diff --git a/src/components/ui/card/post/mockData/mockData.json b/src/components/ui/card/post/mockData/mockData.json
new file mode 100644
index 0000000..019c0f9
--- /dev/null
+++ b/src/components/ui/card/post/mockData/mockData.json
@@ -0,0 +1,231 @@
+{
+ "offset": 0,
+ "limit": 3,
+ "address": [],
+ "count": 6,
+ "hasNext": false,
+ "items": [
+ {
+ "item": {
+ "id": "notice-001",
+ "hourlyPay": 18000,
+ "startsAt": "2025-10-11T11:00:00Z",
+ "workhour": 4,
+ "description": "주말 점심 시간대 근무자를 모집합니다.",
+ "closed": false,
+ "shop": {
+ "item": {
+ "id": "shop-bridge",
+ "name": "한강 브런치 카페",
+ "category": "카페",
+ "address1": "서울시 용산구",
+ "address2": "한강로 2가 123-45",
+ "description": "한강 뷰를 자랑하는 브런치 카페",
+ "imageUrl": "https://picsum.photos/id/1080/640/360",
+ "originalHourlyPay": 18000
+ },
+ "href": "/shops/shop-bridge"
+ }
+ },
+ "links": [
+ {
+ "rel": "self",
+ "description": "공고 상세",
+ "method": "GET",
+ "href": "/notices/notice-001"
+ },
+ {
+ "rel": "shop",
+ "description": "가게 상세",
+ "method": "GET",
+ "href": "/shops/shop-bridge"
+ }
+ ]
+ },
+ {
+ "item": {
+ "id": "notice-002",
+ "hourlyPay": 20000,
+ "startsAt": "2026-08-15T18:30:00Z",
+ "workhour": 5,
+ "description": "저녁 피크 타임 대응 인력을 찾습니다.",
+ "closed": false,
+ "shop": {
+ "item": {
+ "id": "shop-chicken",
+ "name": "홍대 치킨 공방",
+ "category": "음식점",
+ "address1": "서울시 마포구",
+ "address2": "어울마당로 35",
+ "description": "수제 치킨 전문점",
+ "imageUrl": "https://picsum.photos/id/292/640/360",
+ "originalHourlyPay": 13000
+ },
+ "href": "/shops/shop-chicken"
+ }
+ },
+ "links": [
+ {
+ "rel": "self",
+ "description": "공고 상세",
+ "method": "GET",
+ "href": "/notices/notice-002"
+ },
+ {
+ "rel": "shop",
+ "description": "가게 상세",
+ "method": "GET",
+ "href": "/shops/shop-chicken"
+ }
+ ]
+ },
+ {
+ "item": {
+ "id": "notice-003",
+ "hourlyPay": 9500,
+ "startsAt": "2023-07-10T09:00:00Z",
+ "workhour": 6,
+ "description": "오전 반찬 포장 보조 인력을 찾습니다.",
+ "closed": true,
+ "shop": {
+ "item": {
+ "id": "shop-deli",
+ "name": "성수 반찬가게",
+ "category": "식품",
+ "address1": "서울시 성동구",
+ "address2": "성수일로 55",
+ "description": "수제로 만든 반찬 판매점",
+ "imageUrl": "https://picsum.photos/id/1060/640/360",
+ "originalHourlyPay": 9000
+ },
+ "href": "/shops/shop-deli"
+ }
+ },
+ "links": [
+ {
+ "rel": "self",
+ "description": "공고 상세",
+ "method": "GET",
+ "href": "/notices/notice-003"
+ },
+ {
+ "rel": "shop",
+ "description": "가게 상세",
+ "method": "GET",
+ "href": "/shops/shop-deli"
+ }
+ ]
+ },
+ {
+ "item": {
+ "id": "notice-004",
+ "hourlyPay": 16000,
+ "startsAt": "2025-09-10T10:00:00Z",
+ "workhour": 5,
+ "description": "평일 오전 카운터 지원 인력을 모집합니다.",
+ "closed": false,
+ "shop": {
+ "item": {
+ "id": "shop-bakery",
+ "name": "합정 베이커리",
+ "category": "카페",
+ "address1": "서울시 마포구",
+ "address2": "합정역로 80",
+ "description": "천연 발효종으로 빵을 만드는 베이커리",
+ "imageUrl": "https://picsum.photos/id/1040/640/360",
+ "originalHourlyPay": 14000
+ },
+ "href": "/shops/shop-bakery"
+ }
+ },
+ "links": [
+ {
+ "rel": "self",
+ "description": "공고 상세",
+ "method": "GET",
+ "href": "/notices/notice-004"
+ },
+ {
+ "rel": "shop",
+ "description": "가게 상세",
+ "method": "GET",
+ "href": "/shops/shop-bakery"
+ }
+ ]
+ },
+ {
+ "item": {
+ "id": "notice-005",
+ "hourlyPay": 10000,
+ "startsAt": "2025-09-05T14:00:00Z",
+ "workhour": 3,
+ "description": "오후 피크 시간대 서빙 인력을 찾습니다.",
+ "closed": false,
+ "shop": {
+ "item": {
+ "id": "shop-ramen",
+ "name": "이태원 라멘집",
+ "category": "일식",
+ "address1": "서울시 용산구",
+ "address2": "이태원동 123-10",
+ "description": "후쿠오카식 진한 육수 라멘",
+ "imageUrl": "https://picsum.photos/id/1050/640/360",
+ "originalHourlyPay": 10000
+ },
+ "href": "/shops/shop-ramen"
+ }
+ },
+ "links": [
+ {
+ "rel": "self",
+ "description": "공고 상세",
+ "method": "GET",
+ "href": "/notices/notice-005"
+ },
+ {
+ "rel": "shop",
+ "description": "가게 상세",
+ "method": "GET",
+ "href": "/shops/shop-ramen"
+ }
+ ]
+ },
+ {
+ "item": {
+ "id": "notice-006",
+ "hourlyPay": 9000,
+ "startsAt": "2023-07-15T07:00:00Z",
+ "workhour": 4,
+ "description": "새벽 도넛 포장 보조 인력을 찾습니다.",
+ "closed": true,
+ "shop": {
+ "item": {
+ "id": "shop-donut",
+ "name": "망원 도넛 하우스",
+ "category": "디저트",
+ "address1": "서울시 마포구",
+ "address2": "망원로 67",
+ "description": "수제 도넛 전문점",
+ "imageUrl": "https://picsum.photos/id/1062/640/360",
+ "originalHourlyPay": 8500
+ },
+ "href": "/shops/shop-donut"
+ }
+ },
+ "links": [
+ {
+ "rel": "self",
+ "description": "공고 상세",
+ "method": "GET",
+ "href": "/notices/notice-006"
+ },
+ {
+ "rel": "shop",
+ "description": "가게 상세",
+ "method": "GET",
+ "href": "/shops/shop-donut"
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/components/ui/card/post/mockData/postWrapper.tsx b/src/components/ui/card/post/mockData/postWrapper.tsx
new file mode 100644
index 0000000..6db0407
--- /dev/null
+++ b/src/components/ui/card/post/mockData/postWrapper.tsx
@@ -0,0 +1,37 @@
+import Post from '@/components/ui/card/post/post';
+import type { PostCard } from '@/types/notice';
+import mockResponse from './mockData.json';
+
+// mockData 용 페이지
+
+type RawNotice = (typeof mockResponse)['items'][number];
+
+const toPostCard = ({ item }: RawNotice): PostCard => {
+ const shop = item.shop.item;
+
+ return {
+ id: item.id,
+ hourlyPay: item.hourlyPay,
+ startsAt: item.startsAt,
+ workhour: item.workhour,
+ closed: item.closed,
+ shopId: shop.id,
+ name: shop.name,
+ address1: shop.address1,
+ imageUrl: shop.imageUrl,
+ originalHourlyPay: shop.originalHourlyPay,
+ };
+};
+
+const PostWrapper = () => {
+ const notices: PostCard[] = mockResponse.items.map(toPostCard);
+ return (
+
+ {notices.map(notice => (
+
+ ))}
+
+ );
+};
+
+export default PostWrapper;
diff --git a/src/components/ui/card/post/post.stories.tsx b/src/components/ui/card/post/post.stories.tsx
new file mode 100644
index 0000000..accae4c
--- /dev/null
+++ b/src/components/ui/card/post/post.stories.tsx
@@ -0,0 +1,62 @@
+import type { PostCard } from '@/types/notice';
+import type { Meta, StoryObj } from '@storybook/react';
+import Post from './post';
+
+const baseNotice: PostCard = {
+ id: 'notice-001',
+ hourlyPay: 18000,
+ startsAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
+ workhour: 4,
+ closed: false,
+ name: '한강 브런치 카페',
+ address1: '서울시 용산구',
+ imageUrl: 'https://picsum.photos/id/1080/640/360',
+ originalHourlyPay: 15000,
+ shopId: 'notice-001',
+};
+
+const meta = {
+ title: 'UI/Post',
+ component: Post,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ notice: baseNotice,
+ },
+};
+
+export const Expired: Story = {
+ args: {
+ notice: {
+ ...baseNotice,
+ id: 'notice-002',
+ startsAt: '2023-08-01T11:00:00Z',
+ hourlyPay: 20000,
+ originalHourlyPay: 13000,
+ shopId: 'notice-002',
+ },
+ },
+};
+
+export const Closed: Story = {
+ args: {
+ notice: {
+ ...baseNotice,
+ id: 'notice-003',
+ closed: true,
+ hourlyPay: 9500,
+ originalHourlyPay: 9000,
+ startsAt: '2023-07-01T09:00:00Z',
+ shopId: 'notice-003',
+ },
+ },
+};
diff --git a/src/components/ui/card/post/post.styles.ts b/src/components/ui/card/post/post.styles.ts
new file mode 100644
index 0000000..cb634da
--- /dev/null
+++ b/src/components/ui/card/post/post.styles.ts
@@ -0,0 +1,11 @@
+import { cardLayout } from '@/components/ui/card/card.styles';
+import { cn } from '@/lib/utils/cn';
+import { cva } from 'class-variance-authority';
+
+export const postFrame = cva(cn(cardLayout.frame(), 'block p-3 tablet:rounded-2xl tablet:p-4'));
+
+export const postImageDimmed = cva(
+ 'absolute inset-0 z-[2] flex items-center justify-center bg-modal-dimmed text-heading-s text-zinc-300 font-bold'
+);
+
+
diff --git a/src/components/ui/card/post/post.tsx b/src/components/ui/card/post/post.tsx
new file mode 100644
index 0000000..f05c490
--- /dev/null
+++ b/src/components/ui/card/post/post.tsx
@@ -0,0 +1,73 @@
+import { cardLayout, CardStatusVariant } from '@/components/ui/card/card.styles';
+import CardBadge from '@/components/ui/card/cardBadge';
+import CardImage from '@/components/ui/card/cardImage';
+import CardInfo from '@/components/ui/card/cardInfo';
+import useAuth from '@/hooks/useAuth';
+import { getTime } from '@/lib/utils/dateFormatter';
+import { formatNumber } from '@/lib/utils/formatNumber';
+import { getNoticeStatus } from '@/lib/utils/getNoticeStatus';
+import type { PostCard } from '@/types/notice';
+import Link from 'next/link';
+import { postFrame, postImageDimmed } from './post.styles';
+
+interface PostProps {
+ notice: PostCard;
+}
+const STATUS_LABEL = {
+ expired: '지난 공고',
+ closed: '공고 마감',
+} as const;
+
+const Post = ({ notice }: PostProps) => {
+ const { user } = useAuth();
+ const {
+ id,
+ hourlyPay,
+ startsAt,
+ workhour,
+ closed,
+ originalHourlyPay,
+ imageUrl,
+ name,
+ address1,
+ shopId,
+ } = notice;
+ const status = getNoticeStatus(closed, startsAt);
+ const { date, startTime, endTime } = getTime(startsAt, workhour);
+ const statusVariant: CardStatusVariant = status === 'open' ? 'open' : 'inactive';
+ const href =
+ user && user.shop ? `/employer/shops/${shopId}/notices/${id}` : `/notices/${shopId}/${id}`;
+
+ return (
+
+
+
+ {status !== 'open' && {STATUS_LABEL[status]}
}
+
+
+ {name}
+
+
+
+ {date} {startTime} ~ {endTime} ({workhour}시간)
+
+
+ {address1}
+
+
+ 시급 {''}
+
+ {formatNumber(hourlyPay)}원
+
+
+
+
+ );
+};
+export default Post;
diff --git a/src/components/ui/dropdown/dropdown.stories.tsx b/src/components/ui/dropdown/dropdown.stories.tsx
new file mode 100644
index 0000000..a70f527
--- /dev/null
+++ b/src/components/ui/dropdown/dropdown.stories.tsx
@@ -0,0 +1,55 @@
+// src/components/ui/dropdown/dropdown.stories.tsx
+import { ADDRESS_CODE, CATEGORY_CODE, SORT_CODE } from '@/constants/dropdown';
+import type { Meta, StoryObj } from '@storybook/nextjs';
+import { useState } from 'react';
+import Dropdown from './dropdown';
+
+const OPTIONS_MAP = { CATEGORY_CODE, ADDRESS_CODE, SORT_CODE };
+
+const meta: Meta = {
+ title: 'UI/Dropdown',
+ component: Dropdown,
+ tags: ['autodocs'],
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+ args: {
+ name: 'status',
+ ariaLabel: '카테고리',
+ placeholder: '카테고리를 선택하세요',
+ values: CATEGORY_CODE,
+ },
+ argTypes: {
+ values: { control: 'select', options: Object.keys(OPTIONS_MAP), mapping: OPTIONS_MAP },
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Medium: Story = {
+ render: args => {
+ const [value, setValue] = useState();
+ return ;
+ },
+ args: { values: ADDRESS_CODE },
+};
+
+export const Small: Story = {
+ render: args => {
+ const [value, setValue] = useState();
+ return ;
+ },
+ args: { size: 'sm', name: 'status-sm' },
+};
+
+export const WithInitialValue: Story = {
+ render: args => {
+ const [value, setValue] = useState(CATEGORY_CODE[1]);
+ return ;
+ },
+};
diff --git a/src/components/ui/dropdown/dropdown.styles.ts b/src/components/ui/dropdown/dropdown.styles.ts
new file mode 100644
index 0000000..17537db
--- /dev/null
+++ b/src/components/ui/dropdown/dropdown.styles.ts
@@ -0,0 +1,16 @@
+export const DROPDOWN_STYLE = {
+ base: 'relative flex-1 text-left min-w-[110px] focus-visible:outline-red-300',
+ md: 'base-input !pr-10',
+ sm: 'rounded-md bg-gray-100 py-1.5 pl-3 pr-7 text-body-s font-bold',
+} as const;
+export const DROPDOWN_ICON_STYLE = {
+ base: 'absolute top-1/2 -translate-y-1/2',
+ md: 'right-5',
+ sm: 'right-3',
+} as const;
+
+export const DROPDOWN_ITEM_STYLE = {
+ base: 'border-b-[1px] last:border-b-0 block w-full whitespace-nowrap border-gray-200 px-5 text-body-s hover:bg-gray-100',
+ md: 'py-3',
+ sm: 'py-2',
+} as const;
diff --git a/src/components/ui/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx
new file mode 100644
index 0000000..1a741d7
--- /dev/null
+++ b/src/components/ui/dropdown/dropdown.tsx
@@ -0,0 +1,113 @@
+import { Icon } from '@/components/ui/icon';
+import useClickOutside from '@/hooks/useClickOutside';
+import useEscapeKey from '@/hooks/useEscapeKey';
+import useSafeRef from '@/hooks/useSafeRef';
+import useToggle from '@/hooks/useToggle';
+import { cn } from '@/lib/utils/cn';
+import { DROPDOWN_ICON_STYLE, DROPDOWN_ITEM_STYLE, DROPDOWN_STYLE } from './dropdown.styles';
+import useDropdown from './hooks/useDropdown';
+
+interface DropdownProps {
+ name: string;
+ ariaLabel: string;
+ selected: T | undefined; // Controlled value
+ values: readonly T[];
+ size?: 'md' | 'sm';
+ placeholder?: string;
+ className?: string;
+ onChange: (value: T) => void;
+}
+
+// EX :
+const Dropdown = ({
+ name,
+ ariaLabel,
+ values,
+ size = 'md',
+ selected,
+ placeholder = '선택해주세요',
+ className,
+ onChange,
+}: DropdownProps) => {
+ const { isOpen, toggle, setClose } = useToggle();
+ const [attachDropdownRef, dropdownRef] = useSafeRef();
+ const [attachTriggerRef, triggerRef] = useSafeRef();
+ const [attachListRef, listRef] = useSafeRef();
+
+ const handleSelect = (val: T) => {
+ onChange(val);
+ setClose();
+ // triggerRef.current?.focus();
+ };
+
+ const { cursorIndex, position } = useDropdown({
+ values,
+ isOpen,
+ listRef,
+ triggerRef,
+ onSelect: handleSelect,
+ });
+ useClickOutside(dropdownRef, setClose);
+ useEscapeKey(setClose);
+ return (
+
+ {/* form 제출 대응 */}
+
+
+ {/* 옵션 버튼 */}
+
+
+ {/* 옵션 리스트 */}
+ {isOpen && (
+
+ {values.map((value, index) => (
+
+ ))}
+
+ )}
+
+ );
+};
+export default Dropdown;
diff --git a/src/components/ui/dropdown/hooks/useDropdown.ts b/src/components/ui/dropdown/hooks/useDropdown.ts
new file mode 100644
index 0000000..0dfbca3
--- /dev/null
+++ b/src/components/ui/dropdown/hooks/useDropdown.ts
@@ -0,0 +1,32 @@
+import { RefObject } from 'react';
+import useDropdownPosition from './useDropdownPosition';
+import useDropdownScroll from './useDropdownScroll';
+import useKeyboardNavigation from './useKeyboardNavigation';
+
+interface UseDropdownProps {
+ values: readonly T[];
+ isOpen: boolean;
+ triggerRef: RefObject;
+ listRef: RefObject;
+ onSelect: (value: T) => void;
+}
+
+const useDropdown = ({
+ values,
+ isOpen,
+ triggerRef,
+ listRef,
+ onSelect,
+}: UseDropdownProps) => {
+ const position = useDropdownPosition(triggerRef);
+ const { cursorIndex } = useKeyboardNavigation({ isOpen, values, onSelect });
+
+ useDropdownScroll(listRef, cursorIndex);
+
+ return {
+ cursorIndex,
+ position,
+ };
+};
+
+export default useDropdown;
diff --git a/src/components/ui/dropdown/hooks/useDropdownPosition.ts b/src/components/ui/dropdown/hooks/useDropdownPosition.ts
new file mode 100644
index 0000000..e51c5ba
--- /dev/null
+++ b/src/components/ui/dropdown/hooks/useDropdownPosition.ts
@@ -0,0 +1,28 @@
+import { RefObject, useEffect, useState } from 'react';
+
+const useDropdownPosition = (triggerRef: RefObject, threshold = 300) => {
+ const [position, setPosition] = useState<'top' | 'bottom'>('bottom');
+
+ useEffect(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+ // 트리거 기준으로 아래쪽 여유 공간 < threshold 이면 위로, 아니면 아래로 배치
+ const updatePosition = () => {
+ const rect = trigger.getBoundingClientRect();
+ const viewportHeight = window.innerHeight;
+ setPosition(viewportHeight - rect.bottom < threshold ? 'top' : 'bottom');
+ };
+
+ updatePosition();
+ window.addEventListener('resize', updatePosition);
+ window.addEventListener('scroll', updatePosition, true);
+ return () => {
+ window.removeEventListener('resize', updatePosition);
+ window.removeEventListener('scroll', updatePosition, true);
+ };
+ }, [triggerRef, threshold]);
+
+ return position;
+};
+
+export default useDropdownPosition;
diff --git a/src/components/ui/dropdown/hooks/useDropdownScroll.ts b/src/components/ui/dropdown/hooks/useDropdownScroll.ts
new file mode 100644
index 0000000..cf00cbe
--- /dev/null
+++ b/src/components/ui/dropdown/hooks/useDropdownScroll.ts
@@ -0,0 +1,20 @@
+import { RefObject, useEffect } from 'react';
+
+const useDropdownScroll = (listRef: RefObject, cursorIndex: number) => {
+ useEffect(() => {
+ const list = listRef.current;
+ if (!list || cursorIndex < 0) return;
+
+ const item = list.children[cursorIndex] as HTMLElement | undefined;
+ if (!item) return;
+ const itemTop = item.offsetTop;
+ const itemBottom = itemTop + item.offsetHeight;
+ const viewTop = list.scrollTop;
+ const viewBottom = viewTop + list.clientHeight;
+
+ if (itemTop < viewTop) list.scrollTop = itemTop;
+ else if (itemBottom > viewBottom) list.scrollTop = itemBottom - list.clientHeight;
+ }, [cursorIndex, listRef]);
+};
+
+export default useDropdownScroll;
diff --git a/src/components/ui/dropdown/hooks/useKeyboardNavigation.ts b/src/components/ui/dropdown/hooks/useKeyboardNavigation.ts
new file mode 100644
index 0000000..66d52f6
--- /dev/null
+++ b/src/components/ui/dropdown/hooks/useKeyboardNavigation.ts
@@ -0,0 +1,53 @@
+import { useEffect, useState } from 'react';
+
+interface UseKeyboardNavigationProps {
+ isOpen: boolean;
+ values: readonly T[];
+ onSelect: (value: T) => void;
+}
+
+const useKeyboardNavigation = ({
+ isOpen,
+ values,
+ onSelect,
+}: UseKeyboardNavigationProps) => {
+ const [cursorIndex, setCursorIndex] = useState(-1);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setCursorIndex(-1);
+ return;
+ }
+
+ const total = values.length;
+ if (!total) return;
+
+ // 방향키를 한 방향으로 누를때 순환 구조 로직
+ const handleKeyDown = (e: KeyboardEvent) => {
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ setCursorIndex(prev => (prev + 1) % total);
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ setCursorIndex(prev => (prev - 1 + total) % total);
+ break;
+ case 'Enter':
+ e.preventDefault();
+ if (cursorIndex >= 0) onSelect(values[cursorIndex]);
+ break;
+ case 'Escape':
+ e.preventDefault();
+ setCursorIndex(-1);
+ break;
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen, values, cursorIndex, onSelect]);
+
+ return { cursorIndex, setCursorIndex };
+};
+export default useKeyboardNavigation;
diff --git a/src/components/ui/dropdown/index.ts b/src/components/ui/dropdown/index.ts
new file mode 100644
index 0000000..5a4b926
--- /dev/null
+++ b/src/components/ui/dropdown/index.ts
@@ -0,0 +1 @@
+export { default as Dropdown } from './dropdown';
diff --git a/src/components/ui/filter/components/filterBody.tsx b/src/components/ui/filter/components/filterBody.tsx
new file mode 100644
index 0000000..b824af3
--- /dev/null
+++ b/src/components/ui/filter/components/filterBody.tsx
@@ -0,0 +1,122 @@
+import { filterLayout } from '@/components/ui/filter/filter.styles';
+import { Icon } from '@/components/ui/icon';
+import { DateInput, Input } from '@/components/ui/input';
+import { ADDRESS_CODE } from '@/constants/dropdown';
+import { cn } from '@/lib/utils/cn';
+import { parseRFC3339 } from '@/lib/utils/dateFormatter';
+import { formatNumber } from '@/lib/utils/formatNumber';
+import { FilterQuery } from '@/types/api';
+import { ChangeEvent, useMemo } from 'react';
+
+interface FilterBodyProps {
+ formData: FilterQuery;
+ onChange: (updater: (prev: FilterQuery) => FilterQuery) => void;
+}
+
+const FilterBody = ({ formData, onChange }: FilterBodyProps) => {
+ const startAt = parseRFC3339(formData.startsAtGte);
+ const pay = useMemo(
+ () => (formData.hourlyPayGte ? formatNumber(formData.hourlyPayGte) : ''),
+ [formData.hourlyPayGte]
+ );
+ const locations = formData.address ?? [];
+ const locationList = ADDRESS_CODE;
+
+ const addLocation = (loc: string) => {
+ if (locations.includes(loc)) return;
+ onChange(prev => ({ ...prev, address: [...locations, loc] }));
+ };
+
+ const removeLocation = (loc: string) => {
+ const next = locations.filter(v => v !== loc);
+ onChange(prev => ({ ...prev, address: next }));
+ };
+
+ const handleDateChange = (date: Date | string) => {
+ if (typeof date === 'string') return;
+
+ const now = new Date();
+ const selected = new Date(date);
+
+ // 선택한 날짜가 오늘이라면, 현재 시각 기준으로 설정 + 60초로 서버 시차문제 방지
+ if (
+ selected.getFullYear() === now.getFullYear() &&
+ selected.getMonth() === now.getMonth() &&
+ selected.getDate() === now.getDate()
+ ) {
+ selected.setHours(now.getHours(), now.getMinutes(), now.getSeconds() + 60, 0);
+ } else {
+ // 미래 날짜면 00시로
+ selected.setHours(0, 0, 0, 0);
+ }
+
+ const rfc3339String = selected.toISOString();
+ onChange(prev => ({ ...prev, startsAtGte: rfc3339String }));
+ };
+
+ const handlePayChange = ({ target }: ChangeEvent) => {
+ const digits = target.value.replace(/[^0-9]/g, '');
+ onChange(prev => ({ ...prev, hourlyPayGte: Number(digits) }));
+ };
+
+ return (
+
+ );
+};
+export default FilterBody;
diff --git a/src/components/ui/filter/components/filterFooter.tsx b/src/components/ui/filter/components/filterFooter.tsx
new file mode 100644
index 0000000..eb5f2a9
--- /dev/null
+++ b/src/components/ui/filter/components/filterFooter.tsx
@@ -0,0 +1,21 @@
+import { Button } from '@/components/ui/button';
+import { filterLayout } from '@/components/ui/filter/filter.styles';
+
+interface FilterFooterProps {
+ onReset: () => void;
+ onSubmit: () => void;
+}
+const FilterFooter = ({ onReset, onSubmit }: FilterFooterProps) => {
+ return (
+
+
+
+
+ );
+};
+
+export default FilterFooter;
diff --git a/src/components/ui/filter/components/filterHeader.tsx b/src/components/ui/filter/components/filterHeader.tsx
new file mode 100644
index 0000000..adcc231
--- /dev/null
+++ b/src/components/ui/filter/components/filterHeader.tsx
@@ -0,0 +1,22 @@
+import { filterLayout } from '@/components/ui/filter/filter.styles';
+import { Icon } from '@/components/ui/icon';
+
+interface FilterHeaderProps {
+ onClose?: () => void;
+ activeCount?: number;
+}
+
+const FilterHeader = ({ onClose, activeCount = 0 }: FilterHeaderProps) => {
+ return (
+
+
+ 상세 필터{activeCount > 0 && <>({activeCount})>}
+
+
+
+ );
+};
+
+export default FilterHeader;
diff --git a/src/components/ui/filter/filter.styles.ts b/src/components/ui/filter/filter.styles.ts
new file mode 100644
index 0000000..8d6fc56
--- /dev/null
+++ b/src/components/ui/filter/filter.styles.ts
@@ -0,0 +1,80 @@
+import { cn } from '@/lib/utils/cn';
+import { cva } from 'class-variance-authority';
+
+const filterPosition = cva('bg-background w-full');
+
+const filterStickyContent = cva('sticky left-0 flex border-gray-200');
+
+const filterWrapper = cva(
+ cn(
+ filterPosition(),
+ 'fixed top-0 z-10 h-dvh overflow-hidden rounded-xl border border-gray-200 min-[480px]:absolute min-[480px]:h-fit min-[480px]:w-[390px] min-[480px]:top-[calc(100%+8px)]'
+ )
+);
+
+const filterPlacement = cva('', {
+ variants: {
+ align: {
+ left: 'left-0',
+ right: 'right-0',
+ },
+ },
+ defaultVariants: {
+ align: 'right',
+ },
+});
+
+const filterPadding = cva('px-3 tablet:px-5');
+
+const filterGap = cva('flex flex-col gap-6');
+
+const filterHeader = cva(
+ cn(
+ filterPosition(),
+ filterStickyContent(),
+ filterPadding(),
+ 'top-0 items-center justify-between border-b pb-3 pt-6'
+ )
+);
+
+const filterBody = cva(
+ cn(
+ filterGap(),
+ filterPadding(),
+ 'scroll-bar py-3 h-[calc(100dvh-94px-61px)] min-[480px]:h-fit min-[480px]:max-h-[calc(600px)]'
+ )
+);
+
+const filterFooter = cva(
+ cn(filterPosition(), filterStickyContent(), filterPadding(), 'bottom-0 gap-2 border-t pb-6 pt-3')
+);
+
+const filterLocationWrapper = cva(
+ 'scroll-bar flex h-64 flex-wrap gap-3 rounded-md border border-gray-200 bg-white p-5'
+);
+
+const filterLocationText = cva(
+ 'shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-center text-body-m'
+);
+
+const filterLocation = cva(cn(filterLocationText(), 'hover:bg-red-100 hover:text-red-500'));
+
+const filterLocationSelected = cva(
+ cn(filterLocationText(), 'flex items-center gap-2 bg-red-100 font-bold text-red-500')
+);
+
+export const filterLayout = {
+ position: filterPosition,
+ stickyContent: filterStickyContent,
+ wrapper: filterWrapper,
+ placement: filterPlacement,
+ padding: filterPadding,
+ header: filterHeader,
+ body: filterBody,
+ footer: filterFooter,
+ filterGap,
+ locationWrapper: filterLocationWrapper,
+ locationText: filterLocationText,
+ location: filterLocation,
+ locationSelected: filterLocationSelected,
+};
diff --git a/src/components/ui/filter/filter.tsx b/src/components/ui/filter/filter.tsx
new file mode 100644
index 0000000..5137a69
--- /dev/null
+++ b/src/components/ui/filter/filter.tsx
@@ -0,0 +1,86 @@
+import { DROPDOWN_STYLE } from '@/components/ui/dropdown/dropdown.styles';
+import useClickOutside from '@/hooks/useClickOutside';
+import useEscapeKey from '@/hooks/useEscapeKey';
+import useSafeRef from '@/hooks/useSafeRef';
+import useToggle from '@/hooks/useToggle';
+import { cn } from '@/lib/utils/cn';
+import { FilterQuery } from '@/types/api';
+import { useCallback, useEffect, useState } from 'react';
+import FilterBody from './components/filterBody';
+import FilterFooter from './components/filterFooter';
+import FilterHeader from './components/filterHeader';
+import { filterLayout } from './filter.styles';
+import { getActiveFilterCount } from './getActiveFilterCount';
+
+interface FilterProps {
+ value: FilterQuery;
+ appliedCount: number;
+ onSubmit: (next: FilterQuery) => void;
+ className?: string;
+ align?: 'left' | 'right';
+}
+
+const INIT_DATA: FilterQuery = {
+ address: undefined,
+ startsAtGte: undefined,
+ hourlyPayGte: undefined,
+};
+
+export function normalizeFilter(q: FilterQuery): FilterQuery {
+ return {
+ address: q.address && q.address.length > 0 ? q.address : undefined,
+ startsAtGte: q.startsAtGte ?? undefined,
+ hourlyPayGte: typeof q.hourlyPayGte === 'number' ? q.hourlyPayGte : undefined,
+ };
+}
+
+const Filter = ({ value, onSubmit, appliedCount, className, align = 'right' }: FilterProps) => {
+ const { isOpen, setClose, toggle } = useToggle();
+ const [draft, setDraft] = useState(value);
+ const [attachFilterRef, filterRef] = useSafeRef();
+
+ const handleSubmit = useCallback(() => {
+ onSubmit(normalizeFilter(draft));
+ setClose();
+ }, [draft, onSubmit, setClose]);
+
+ const handleReset = useCallback(() => {
+ setDraft(INIT_DATA);
+ }, []);
+
+ useEffect(() => {
+ setDraft(value);
+ }, [value]);
+
+ useClickOutside(filterRef, setClose);
+ useEscapeKey(setClose);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+export default Filter;
diff --git a/src/components/ui/filter/getActiveFilterCount.ts b/src/components/ui/filter/getActiveFilterCount.ts
new file mode 100644
index 0000000..ffc0f7a
--- /dev/null
+++ b/src/components/ui/filter/getActiveFilterCount.ts
@@ -0,0 +1,9 @@
+import { FilterQuery } from '@/types/api';
+
+export function getActiveFilterCount(q: FilterQuery): number {
+ const addressCount = q.address ? q.address.length : 0;
+ const dateCount = q.startsAtGte ? 1 : 0;
+ const payCount = typeof q.hourlyPayGte === 'number' ? 1 : 0;
+
+ return addressCount + dateCount + payCount;
+}
diff --git a/src/components/ui/filter/index.ts b/src/components/ui/filter/index.ts
new file mode 100644
index 0000000..bb58a2c
--- /dev/null
+++ b/src/components/ui/filter/index.ts
@@ -0,0 +1 @@
+export { default as Filter } from './filter';
diff --git a/src/stories/DesignTokens/Icon.stories.tsx b/src/components/ui/icon/Icon.stories.tsx
similarity index 97%
rename from src/stories/DesignTokens/Icon.stories.tsx
rename to src/components/ui/icon/Icon.stories.tsx
index ca72950..e447057 100644
--- a/src/stories/DesignTokens/Icon.stories.tsx
+++ b/src/components/ui/icon/Icon.stories.tsx
@@ -1,4 +1,4 @@
-import type { Meta, StoryObj } from '@storybook/react';
+import type { Meta, StoryObj } from '@storybook/nextjs';
import { ICONS, ICON_SIZES, type IconName, type IconSize } from '@/constants/icon';
import { Icon } from '@/components/ui';
diff --git a/src/components/ui/icon/icon.tsx b/src/components/ui/icon/icon.tsx
index 70dbb34..4a2496b 100644
--- a/src/components/ui/icon/icon.tsx
+++ b/src/components/ui/icon/icon.tsx
@@ -1,11 +1,20 @@
-import { forwardRef } from 'react';
+import {
+ ICONS,
+ ICON_RESPONSIVE_SIZES,
+ ICON_SIZES,
+ type IconName,
+ type IconResponsiveSize,
+ type IconSize,
+} from '@/constants/icon';
import { cn } from '@/lib/utils/cn';
-import { ICONS, ICON_SIZES, type IconName, type IconSize } from '@/constants/icon';
+import { forwardRef } from 'react';
interface IconProps extends React.HTMLAttributes {
iconName: IconName;
- iconSize?: IconSize;
+ iconSize?: IconSize; // 모바일 기본 사이즈
+ bigScreenSize?: IconResponsiveSize; // PC에서 사이즈 다를때 사용
className?: string;
- ariaLabel?: string;
+ ariaLabel?: string; // 접근성 라벨
+ decorative?: boolean;
}
/**
@@ -16,20 +25,33 @@ interface IconProps extends React.HTMLAttributes {
*/
const Icon = forwardRef(
- ({ iconName, iconSize = 'md', className, ariaLabel, ...props }, ref) => {
+ (
+ {
+ iconName,
+ iconSize = 'md',
+ bigScreenSize,
+ className,
+ ariaLabel,
+ decorative = false,
+ ...props
+ },
+ ref
+ ) => {
const url = ICONS[iconName];
const size = ICON_SIZES[iconSize];
+ const bigSize = bigScreenSize ? ICON_RESPONSIVE_SIZES[bigScreenSize] : '';
return (
);
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 127303e..0505f5e 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1 +1,19 @@
+export { Badge, StatusBadge } from '@/components/ui/badge';
+export {
+ Calendar,
+ CalendarHeader,
+ DayViewMode,
+ MonthViewMode,
+ TimeSelector,
+ YearViewMode,
+} from '@/components/ui/calendar';
+export { DateInput, Input, TimeInput } from '@/components/ui/input';
+export { Table } from '@/components/ui/table';
+export { Button } from './button';
+export { Notice, Post } from './card';
+export { Dropdown } from './dropdown';
+export { Filter } from './filter';
export { Icon } from './icon';
+export { Modal, Notification } from './modal';
+export { Pagination } from './pagination';
+export { SkeletonUI } from './skeleton';
diff --git a/src/components/ui/input/DateInput.tsx b/src/components/ui/input/DateInput.tsx
new file mode 100644
index 0000000..31dda31
--- /dev/null
+++ b/src/components/ui/input/DateInput.tsx
@@ -0,0 +1,134 @@
+import { Calendar } from '@/components/ui/calendar';
+import useClickOutside from '@/hooks/useClickOutside';
+import useToggle from '@/hooks/useToggle';
+import { formatDate, formatWithDots } from '@/lib/utils/dateFormatter';
+import { DateInputProps } from '@/types/calendar';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import Input from './input';
+
+export default function DateInput({
+ id = 'date',
+ label = '날짜 선택',
+ className,
+ value,
+ onChange,
+ requiredMark = false,
+ error,
+}: DateInputProps) {
+ const { isOpen, toggle, setClose } = useToggle(false);
+ const wrapperRef = useRef(null);
+ const [selectedDate, setSelectedDate] = useState(null);
+ const [inputValue, setInputValue] = useState(''); // typing 사용
+ const [dateError, setDateError] = useState('');
+
+ useClickOutside(wrapperRef, () => {
+ if (isOpen) setClose();
+ });
+
+ useEffect(() => {
+ if (value) {
+ setSelectedDate(value);
+ setInputValue(formatDate(value));
+ } else {
+ setSelectedDate(null);
+ setInputValue('');
+ }
+ }, [value]);
+
+ // 날짜 업데이트 중앙 관리
+ const updateDate = useCallback(
+ (date: Date) => {
+ setSelectedDate(date);
+ setInputValue(formatDate(date));
+ setClose();
+ onChange?.(date);
+ },
+ [onChange, setClose]
+ );
+
+ // 날짜 선택
+ const handleDateSelect = useCallback(
+ (date: Date) => {
+ updateDate(date);
+ },
+ [updateDate]
+ );
+
+ // typing
+ const maxDaysList = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ const isLeapYear = (year: number) => {
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+ };
+ const getMaxDay = (year: number, month: number) => {
+ if (month === 2 && isLeapYear(year)) return 29;
+ return maxDaysList[month - 1];
+ };
+
+ const validateDate = (year: number, month: number, day: number) => {
+ const maxDay = getMaxDay(year, month);
+
+ if (month < 1 || month > 12 || day < 1 || day > maxDay) {
+ return { valid: false, error: '올바른 날짜를 입력하세요.' };
+ }
+
+ const inputDate = new Date(year, month - 1, day);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (inputDate < today) {
+ return { valid: false, error: '이전 날짜는 선택할 수 없습니다.' };
+ }
+
+ return { valid: true, date: inputDate };
+ };
+
+ const handleDateInputChange = (e: React.ChangeEvent) => {
+ const newTypedNumbers = e.target.value.replace(/[^0-9]/g, '');
+ const typedLength = newTypedNumbers.length;
+
+ if (typedLength > 8) return;
+
+ setDateError('');
+ setInputValue(formatWithDots(newTypedNumbers));
+
+ if (typedLength === 8) {
+ const year = parseInt(newTypedNumbers.slice(0, 4));
+ const month = parseInt(newTypedNumbers.slice(4, 6));
+ const day = parseInt(newTypedNumbers.slice(6, 8));
+
+ const { valid, error, date } = validateDate(year, month, day);
+
+ if (!valid) {
+ setDateError(error ?? '');
+ return;
+ }
+ if (date) {
+ setDateError('');
+ updateDate(date);
+ }
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/input/TimeInput.tsx b/src/components/ui/input/TimeInput.tsx
new file mode 100644
index 0000000..3e35439
--- /dev/null
+++ b/src/components/ui/input/TimeInput.tsx
@@ -0,0 +1,137 @@
+import TimeSelector from '@/components/ui/calendar/TimeSelector';
+import useClickOutside from '@/hooks/useClickOutside';
+import useToggle from '@/hooks/useToggle';
+import { formatTime } from '@/lib/utils/dateFormatter';
+import { Period } from '@/types/calendar';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import Input from './input';
+
+interface TimeInputProps {
+ label?: string;
+ requiredMark?: boolean;
+ value?: Date | null;
+ onChange?: (value: Date | null) => void;
+}
+
+export default function TimeInput({
+ label = '시간 선택',
+ requiredMark = false,
+ value,
+ onChange,
+}: TimeInputProps) {
+ const { isOpen, toggle, setClose } = useToggle(false);
+ const [period, setPeriod] = useState('오전');
+ const [selectedTime, setSelectedTime] = useState(null);
+ const [inputValue, setInputValue] = useState(''); // typing 사용
+
+ const wrapperRef = useRef(null);
+
+ useClickOutside(wrapperRef, () => {
+ if (isOpen) setClose();
+ });
+
+ // 시간 업데이트 중앙 관리
+ const updateTime = useCallback(
+ (date: Date, selectedPeriod: Period) => {
+ setPeriod(selectedPeriod);
+ setSelectedTime(date);
+ setInputValue(formatTime(date));
+ onChange?.(date);
+ },
+ [onChange]
+ );
+
+ useEffect(() => {
+ if (value) {
+ setSelectedTime(value);
+ setInputValue(formatTime(value));
+ } else {
+ setSelectedTime(null);
+ setInputValue('');
+ }
+ }, [value]);
+
+ // 시간 선택
+ const handleTimeSelect = useCallback(
+ (value: string) => {
+ const parts = value.split(' ');
+ const periodValue = parts.length === 2 ? (parts[0] as Period) : period;
+ const timePart = parts.length === 2 ? parts[1] : parts[0];
+
+ const [hours, minutes] = timePart.split(':').map(Number);
+ if (isNaN(hours) || isNaN(minutes)) return;
+
+ const baseDate = selectedTime ?? new Date();
+ const newDate = new Date(baseDate);
+ newDate.setHours(hours, minutes);
+
+ updateTime(newDate, periodValue);
+ },
+ [selectedTime, updateTime, period]
+ );
+
+ // typing
+ const handleTimeInputChange = (e: React.ChangeEvent) => {
+ const newTypedNumbers = e.target.value.replace(/[^0-9]/g, '');
+ const typedLength = newTypedNumbers.length;
+
+ setInputValue(newTypedNumbers);
+
+ if (typedLength > 4) {
+ const hours = parseInt(newTypedNumbers.slice(0, typedLength - 2));
+
+ if (isNaN(hours) || hours < 1 || hours > 12) {
+ setInputValue(newTypedNumbers.slice(-1));
+ return;
+ }
+ }
+
+ if (typedLength < 3) return;
+
+ const hoursTyped = newTypedNumbers.slice(0, typedLength - 2);
+ const minutesTyped = newTypedNumbers.slice(-2);
+
+ const h = parseInt(hoursTyped);
+ const m = parseInt(minutesTyped);
+
+ if (!isNaN(h) && !isNaN(m)) {
+ if (!(h >= 1 && h <= 12 && m >= 0 && m < 60)) return;
+
+ const periodValue: Period = h > 12 ? '오후' : '오전';
+
+ const baseDate = selectedTime ?? new Date();
+ const newDate = new Date(baseDate);
+ newDate.setHours(h, m);
+
+ updateTime(newDate, periodValue);
+ }
+ };
+
+ const hours = selectedTime ? String(selectedTime.getHours() % 12 || 12).padStart(2, '0') : '12';
+ const minutes = selectedTime ? String(selectedTime.getMinutes()).padStart(2, '0') : '00';
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/input/dateinput.stories.tsx b/src/components/ui/input/dateinput.stories.tsx
new file mode 100644
index 0000000..57f970d
--- /dev/null
+++ b/src/components/ui/input/dateinput.stories.tsx
@@ -0,0 +1,21 @@
+import { Meta, StoryObj } from '@storybook/nextjs';
+import DateInput from './DateInput';
+import Input from './input';
+
+const meta: Meta = {
+ title: 'Form/Input',
+ component: DateInput,
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+// 날짜 입력 인풋 //
+export const Date: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/src/components/ui/input/index.ts b/src/components/ui/input/index.ts
new file mode 100644
index 0000000..2f5eae9
--- /dev/null
+++ b/src/components/ui/input/index.ts
@@ -0,0 +1,3 @@
+export { default as DateInput } from '@/components/ui/input/DateInput';
+export { default as Input } from '@/components/ui/input/input';
+export { default as TimeInput } from '@/components/ui/input/TimeInput';
diff --git a/src/components/ui/input/input.stories.tsx b/src/components/ui/input/input.stories.tsx
new file mode 100644
index 0000000..c6b68c4
--- /dev/null
+++ b/src/components/ui/input/input.stories.tsx
@@ -0,0 +1,84 @@
+// src/components/ui/input/input.stories.tsx
+import Button from '@/components/ui/button/button';
+import type { Meta, StoryObj } from '@storybook/nextjs';
+import { useState } from 'react';
+import Input from './input';
+
+const meta: Meta = {
+ title: 'Form/Input',
+ component: Input,
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Email: Story = {
+ args: { id: 'email', label: '이메일', placeholder: '입력', type: 'email' },
+};
+
+export const Password: Story = {
+ args: { id: 'pw', label: '비밀번호', type: 'password', placeholder: '••••••••' },
+};
+
+export const PasswordConfirm_Error: Story = {
+ args: {
+ id: 'pw2',
+ label: '비밀번호 확인',
+ type: 'password',
+ placeholder: '••••••••',
+ error: '비밀번호가 일치하지 않습니다.',
+ },
+};
+
+/** 시급(원) — 스토리 내부에서 숫자만 허용(컴포넌트 변경 없음) */
+export const WageWithSuffix: Story = {
+ render: () => {
+ const [v, setV] = useState('');
+ return (
+ setV(e.currentTarget.value.replace(/\D+/g, ''))} // 숫자만
+ />
+ );
+ },
+};
+
+/** 미니 폼 데모 */
+export const MiniFormDemo: Story = {
+ render: () => {
+ const [wage2, setWage2] = useState('');
+ return (
+
+
+
+
+ setWage2(e.currentTarget.value.replace(/\D+/g, ''))} // 숫자만
+ />
+
+
+ );
+ },
+};
diff --git a/src/components/ui/input/input.tsx b/src/components/ui/input/input.tsx
new file mode 100644
index 0000000..5e9fc5c
--- /dev/null
+++ b/src/components/ui/input/input.tsx
@@ -0,0 +1,91 @@
+import { cn } from '@/lib/utils/cn';
+import { InputHTMLAttributes, ReactNode } from 'react';
+
+type Props = {
+ label?: string; // 라벨 텍스트
+ requiredMark?: boolean; // 라벨 옆 * 표시
+ error?: string; // 에러 문구(있으면 빨간 테두리/문구)
+ suffix?: ReactNode; // 우측 단위/아이콘(예: '원')
+ className?: string; // 외부 커스텀 클래스
+} & InputHTMLAttributes;
+
+export default function Input({
+ label,
+ requiredMark,
+ error,
+ suffix,
+ className,
+ id,
+ disabled,
+ ...rest // (type, placeholder, value, onChange 등)
+}: Props) {
+ const hasError = Boolean(error);
+ const isDisabled = Boolean(disabled);
+ const errorId = id && hasError ? `${id}-error` : undefined;
+
+ return (
+
+ {/* Label */}
+ {label && (
+
+ )}
+
+ {/* Field */}
+
+
+
+ {/* Suffix (예: 단위/아이콘) */}
+ {suffix && (
+
+ {suffix}
+
+ )}
+
+
+ {/* Error message */}
+ {hasError && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/input/timeinput.stories.tsx b/src/components/ui/input/timeinput.stories.tsx
new file mode 100644
index 0000000..b09c61a
--- /dev/null
+++ b/src/components/ui/input/timeinput.stories.tsx
@@ -0,0 +1,20 @@
+import { Meta, StoryObj } from '@storybook/nextjs';
+import TimeInput from './TimeInput';
+
+const meta: Meta = {
+ title: 'Form/Input',
+ component: TimeInput,
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+// 시간 입력 인풋 //
+export const Time: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/src/components/ui/modal/index.ts b/src/components/ui/modal/index.ts
new file mode 100644
index 0000000..e7f3887
--- /dev/null
+++ b/src/components/ui/modal/index.ts
@@ -0,0 +1,2 @@
+export { default as Notification } from '@/components/ui/modal/notification';
+export { default as Modal } from './modal';
diff --git a/src/components/ui/modal/modal.stories.tsx b/src/components/ui/modal/modal.stories.tsx
new file mode 100644
index 0000000..852c59a
--- /dev/null
+++ b/src/components/ui/modal/modal.stories.tsx
@@ -0,0 +1,92 @@
+import type { Meta, StoryObj } from '@storybook/nextjs';
+import { useState } from 'react';
+import Modal from './modal';
+
+const meta: Meta = {
+ title: 'UI/Modal',
+ component: Modal,
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+/** 1) 경고(Alert) */
+export const AlertWarning: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+ setOpen(false)}
+ variant='warning'
+ title='가게 정보를 먼저 등록해 주세요.'
+ primaryText='확인'
+ onPrimary={() => setOpen(false)}
+ />
+ >
+ );
+ },
+};
+
+/** 2) 확인/취소(Confirm) */
+export const ConfirmSuccess: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+ setOpen(false)}
+ variant='success'
+ title='신청을 거절하시겠어요?'
+ secondaryText='아니오'
+ onSecondary={() => setOpen(false)}
+ primaryText='예'
+ onPrimary={() => setOpen(false)}
+ />
+ >
+ );
+ },
+};
+
+/** 3) 수정(correction) */
+export const Correction: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+ setOpen(false)}
+ variant='success'
+ title='수정이 완료되었습니다.'
+ primaryText='확인'
+ onPrimary={() => setOpen(false)}
+ />
+ >
+ );
+ },
+};
diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx
new file mode 100644
index 0000000..b413c02
--- /dev/null
+++ b/src/components/ui/modal/modal.tsx
@@ -0,0 +1,151 @@
+import { Icon } from '@/components/ui';
+import Button from '@/components/ui/button/button';
+import type { IconName } from '@/constants/icon';
+import { cn } from '@/lib/utils/cn';
+import { ReactNode, useEffect } from 'react';
+import { createPortal } from 'react-dom';
+
+type Variant = 'success' | 'warning';
+
+type ModalProps = {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ description?: ReactNode;
+ variant?: Variant;
+ primaryText: string;
+ onPrimary: () => void;
+ secondaryText?: string;
+ onSecondary?: () => void;
+ closeOnDimmed?: boolean;
+ disablePortal?: boolean;
+ className?: string;
+};
+
+const ICON_MAP: Record = {
+ success: { circle: 'successCircle', glyph: 'success' },
+ warning: { circle: 'warningCircle', glyph: 'warning' },
+};
+
+// ESC 닫기
+function useEscClose(open: boolean, onClose: () => void) {
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [open, onClose]);
+}
+
+/** Header */
+function ModalHeader({ variant, title }: { variant: Variant; title: string }) {
+ return (
+
+
+
+
+
+
{title}
+
+ );
+}
+
+/** Body (optional) */
+function ModalBody({ description }: { description?: ReactNode }) {
+ if (!description) return null;
+ return {description}
;
+}
+
+/** Footer */
+function ModalFooter({
+ primaryText,
+ onPrimary,
+ secondaryText,
+ onSecondary,
+}: {
+ primaryText: string;
+ onPrimary: () => void;
+ secondaryText?: string;
+ onSecondary?: () => void;
+}) {
+ return (
+
+ {secondaryText && onSecondary && (
+
+ )}
+
+
+ );
+}
+
+export default function Modal({
+ open,
+ onClose,
+ title,
+ description,
+ variant = 'warning',
+ primaryText,
+ onPrimary,
+ secondaryText,
+ onSecondary,
+ closeOnDimmed = true,
+ disablePortal = false,
+ className,
+}: ModalProps) {
+ useEscClose(open, onClose);
+ if (!open) return null;
+
+ const node = (
+
+ {closeOnDimmed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ );
+
+ return disablePortal ? node : createPortal(node, document.body);
+}
diff --git a/src/components/ui/modal/notification/Notification.stories.tsx b/src/components/ui/modal/notification/Notification.stories.tsx
new file mode 100644
index 0000000..70d0ccc
--- /dev/null
+++ b/src/components/ui/modal/notification/Notification.stories.tsx
@@ -0,0 +1,146 @@
+// Notification.stories.tsx
+import type { Meta, StoryObj } from '@storybook/nextjs';
+import Notification, { Alert } from './Notification';
+
+const meta: Meta = {
+ title: 'Components/Notification',
+ component: Notification,
+ parameters: {
+ layout: 'padded',
+ actions: {
+ handles: ['onRead'],
+ },
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ 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[],
+ },
+};
diff --git a/src/components/ui/modal/notification/Notification.tsx b/src/components/ui/modal/notification/Notification.tsx
new file mode 100644
index 0000000..42bded5
--- /dev/null
+++ b/src/components/ui/modal/notification/Notification.tsx
@@ -0,0 +1,72 @@
+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, isOpen, onClose }: NotificationProps) {
+ // 제어 모드인지 판별
+ const controlled = typeof isOpen === 'boolean';
+ const [internalOpen, setInternalOpen] = useState(false);
+ const open = controlled ? (isOpen as boolean) : internalOpen;
+ 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 (
+ <>
+ {/* 제어 모드가 아니면 내부 트리거 버튼을 노출 */}
+ {!controlled && (
+
+
+
+ )}
+ {open && (
+
+
+
알림 {notificationCount}개
+
+
+
+
+
+ {SORTED_ALERTS.length === 0 ? (
+
+ ) : (
+
+ {SORTED_ALERTS.map(alert => (
+
+ ))}
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/src/components/ui/modal/notification/NotificationMessage.tsx b/src/components/ui/modal/notification/NotificationMessage.tsx
new file mode 100644
index 0000000..d0e97a9
--- /dev/null
+++ b/src/components/ui/modal/notification/NotificationMessage.tsx
@@ -0,0 +1,61 @@
+import { getTime } from '@/lib/utils/dateFormatter';
+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 (
+
+
+
+ );
+}
diff --git a/src/components/ui/modal/notification/ResultBadge.tsx b/src/components/ui/modal/notification/ResultBadge.tsx
new file mode 100644
index 0000000..9ba4d06
--- /dev/null
+++ b/src/components/ui/modal/notification/ResultBadge.tsx
@@ -0,0 +1,19 @@
+import Icon from '@/components/ui/icon/icon';
+
+export interface ResultBadgeProps {
+ result: 'accepted' | 'rejected';
+}
+const ICON_COLORS: Record = {
+ accepted: 'bg-blue-200',
+ rejected: 'bg-red-400',
+};
+
+export default function ResultBadge({ result }: ResultBadgeProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/modal/notification/index.ts b/src/components/ui/modal/notification/index.ts
new file mode 100644
index 0000000..2159c1a
--- /dev/null
+++ b/src/components/ui/modal/notification/index.ts
@@ -0,0 +1 @@
+export { default } from '@/components/ui/modal/notification/Notification';
diff --git a/src/components/ui/pagination/index.ts b/src/components/ui/pagination/index.ts
new file mode 100644
index 0000000..fe89635
--- /dev/null
+++ b/src/components/ui/pagination/index.ts
@@ -0,0 +1 @@
+export { default as Pagination } from '@/components/ui/pagination/pagination';
diff --git a/src/components/ui/pagination/pagination.tsx b/src/components/ui/pagination/pagination.tsx
new file mode 100644
index 0000000..7a56218
--- /dev/null
+++ b/src/components/ui/pagination/pagination.tsx
@@ -0,0 +1,122 @@
+import { Icon } from '@/components/ui';
+import { cn } from '@/lib/utils/cn';
+import { useEffect, useState } from 'react';
+import { useMediaQuery } from 'react-responsive';
+
+const BUTTON_ALIGN = 'flex items-center justify-center shrink-0';
+interface PaginationProps {
+ total: number; // 전체 개수 (count)
+ offset: number; // 현재 offset
+ limit: number; // 한 페이지당 아이템 수
+ onPageChange: (next: number) => void; // 새 offset 전달
+ className?: string;
+}
+
+/* {
+ const isDesktop = useMediaQuery({ minWidth: 1028 });
+ const isTablet = useMediaQuery({ minWidth: 744, maxWidth: 1027 });
+ const [pageGroupSize, setPageGroupSize] = useState(7);
+ const [pageGroup, setPageGroup] = useState(0);
+
+ const totalPages = total ? Math.ceil(total / limit) : 0;
+ const currentPage = Math.floor(offset / limit) + 1; // offset → page 변환
+
+ useEffect(() => {
+ if (isDesktop) setPageGroupSize(10);
+ else if (isTablet) setPageGroupSize(7);
+ else setPageGroupSize(5);
+ }, [isDesktop, isTablet]);
+
+ useEffect(() => {
+ const newGroup = Math.floor((currentPage - 1) / pageGroupSize);
+ setPageGroup(newGroup);
+ }, [currentPage, pageGroupSize]);
+
+ if (totalPages <= 1) return null;
+
+ const startPage = pageGroup * pageGroupSize + 1;
+ const endPage = Math.min(startPage + pageGroupSize - 1, totalPages);
+ const pageNumbers = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
+ const isPrevDisabled = pageGroup === 0;
+ const isNextDisabled = (pageGroup + 1) * pageGroupSize >= totalPages;
+
+ /* 이전 그룹으로 이동 */
+ const handlePrevPage = () => {
+ if (!isPrevDisabled) {
+ const prevGroup = pageGroup - 1;
+ const prevStartPage = prevGroup * pageGroupSize + 1;
+ setPageGroup(prev => Math.max(prev - 1, 0));
+
+ const newOffset = (prevStartPage - 1) * limit;
+ onPageChange(newOffset);
+ }
+ };
+
+ /* 다음 그룹으로 이동 */
+ const handleNextPage = () => {
+ if (!isNextDisabled) {
+ const nextGroup = pageGroup + 1;
+ const nextStartPage = nextGroup * pageGroupSize + 1;
+ setPageGroup(prev => ((prev + 1) * pageGroupSize < totalPages ? prev + 1 : prev));
+
+ const newOffset = (nextStartPage - 1) * limit;
+ onPageChange(newOffset);
+ }
+ };
+
+ /* 페이지 클릭 시 offset 계산 후 전달 */
+ const handlePageClick = (page: number) => {
+ const newOffset = (page - 1) * limit;
+ onPageChange(newOffset);
+ };
+
+ return (
+
+ {/* 이전 */}
+
+
+ {/* 페이지 번호 */}
+ {pageNumbers.map(page => (
+
+ ))}
+
+ {/* 다음 */}
+
+
+ );
+};
+
+export default Pagination;
diff --git a/src/components/ui/skeleton/index.ts b/src/components/ui/skeleton/index.ts
new file mode 100644
index 0000000..5c3b787
--- /dev/null
+++ b/src/components/ui/skeleton/index.ts
@@ -0,0 +1 @@
+export { default as SkeletonUI } from './skeletonUI';
diff --git a/src/components/ui/skeleton/skeletonUI.tsx b/src/components/ui/skeleton/skeletonUI.tsx
new file mode 100644
index 0000000..fa0e51c
--- /dev/null
+++ b/src/components/ui/skeleton/skeletonUI.tsx
@@ -0,0 +1,21 @@
+import { cn } from '@/lib/utils/cn';
+
+const SkeletonUI = ({ count = 0, className = '' }) => {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+};
+export default SkeletonUI;
diff --git a/src/components/ui/table/Table.stories.tsx b/src/components/ui/table/Table.stories.tsx
new file mode 100644
index 0000000..04c4e95
--- /dev/null
+++ b/src/components/ui/table/Table.stories.tsx
@@ -0,0 +1,195 @@
+import Table from '@/components/ui/table/Table';
+import { TableRowProps } from '@/components/ui/table/TableRowProps';
+import { UserRole, UserType } from '@/types/user';
+import { Meta, StoryObj } from '@storybook/nextjs';
+import { useEffect, useState } from 'react';
+
+const fetchTableData = async (userRole: UserRole) => {
+ return new Promise<{ headers: string[]; data: unknown[] }>(resolve => {
+ setTimeout(() => {
+ if (userRole === 'employer') {
+ resolve({
+ headers: ['신청자', '소개', '전화번호', '상태'],
+ data: [
+ {
+ name: '김강현',
+ bio: '최선을 다해 열심히 일합니다. 다수의 업무 경험을 바탕으로 확실한 일처리 보여드리겠습니다.',
+ phone: '010-1234-5678',
+ status: 'pending',
+ },
+ {
+ name: '서혜진',
+ bio: '열심히 하겠습니다!',
+ phone: '010-1111-2222',
+ status: 'rejected',
+ },
+ {
+ name: '주진혁',
+ bio: '성실한 자세로 열심히 일합니다.',
+ phone: '010-3333-4444',
+ status: 'approved',
+ },
+ {
+ name: '장민혁',
+ bio: '일을 꼼꼼하게 하는 성격입니다.',
+ phone: '010-5555-5555',
+ status: 'approved',
+ },
+ {
+ name: '고기훈',
+ bio: '하루라도 최선을 다해서 일하겠습니다!',
+ phone: '010-6666-6666',
+ status: 'rejected',
+ },
+ {
+ name: '최현수',
+ bio: '열심히 하겠습니다!',
+ phone: '010-1123-5448',
+ status: 'pending',
+ },
+ {
+ name: '강주하',
+ bio: '성실한 자세로 열심히 일합니다.',
+ phone: '010-4123-2323',
+ status: 'approved',
+ },
+ {
+ name: '배수지',
+ bio: '열심히 배우고 일하겠습니다!',
+ phone: '010-3123-1111',
+ status: 'approved',
+ },
+ {
+ name: '강규하',
+ bio: '꼼꼼한 일처리 보여드리겠습니다.',
+ phone: '010-5123-0098',
+ status: 'rejected',
+ },
+ {
+ name: '고선영',
+ bio: '최선을 다해서 일하겠습니다!',
+ phone: '010-6662-6326',
+ status: 'peding',
+ },
+ {
+ name: '박하연',
+ bio: '뽑아주시면 열심히 하겠습니다.',
+ phone: '010-1277-1385',
+ status: 'pending',
+ },
+ {
+ name: '김연아',
+ bio: '잘 부탁드립니다!',
+ phone: '010-1232-6216',
+ status: 'rejected',
+ },
+ ],
+ });
+ } else {
+ resolve({
+ headers: ['가게', '일자', '시급', '상태'],
+ data: [
+ {
+ name: '너구리네 라면집',
+ startsAt: '2025-10-01T11:00',
+ workhour: 2,
+ hourlyPay: '12,500원',
+ status: 'pending',
+ },
+ {
+ name: '너구리네 라면집',
+ startsAt: '2025-10-01T11:00',
+ workhour: 2,
+ hourlyPay: '12,500원',
+ status: 'rejected',
+ },
+ {
+ name: '너구리네 라면집',
+ startsAt: '2025-10-01T11:00',
+ workhour: 2,
+ hourlyPay: '12,500원',
+ status: 'approved',
+ },
+ {
+ name: '너구리네 라면집',
+ startsAt: '2025-10-01T11:00',
+ workhour: 2,
+ hourlyPay: '12,500원',
+ status: 'rejected',
+ },
+ {
+ name: '너구리네 라면집',
+ startsAt: '2025-10-01T11:00',
+ workhour: 2,
+ hourlyPay: '12,500원',
+ status: 'approved',
+ },
+ {
+ name: '너구리네 라면집',
+ startsAt: '2025-10-01T11:00',
+ workhour: 2,
+ hourlyPay: '12,500원',
+ status: 'approved',
+ },
+ ],
+ });
+ }
+ });
+ });
+};
+
+const meta: Meta = {
+ title: 'UI/Table',
+ component: Table,
+ tags: ['autodocs'],
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+function TableWithTestApi({ userRole }: { userRole: UserRole }) {
+ const [headers, setHeaders] = useState([]);
+ const [data, setData] = useState([]);
+ const [offset, setOffset] = useState(0);
+ const limit = 5;
+
+ useEffect(() => {
+ const getData = async () => {
+ const res = await fetchTableData(userRole);
+ setHeaders(res.headers);
+ setData(res.data as TableRowProps[]);
+ };
+ getData();
+ }, [userRole]);
+
+ const count = data.length;
+ const paginatedData = data.slice(offset, offset + limit);
+
+ return (
+
+ );
+}
+
+export const EmployerTable: Story = {
+ args: {
+ userRole: 'employer',
+ },
+ render: args => ,
+};
+
+export const EmployeeTable: Story = {
+ args: {
+ userRole: 'employee',
+ },
+ render: args => ,
+};
diff --git a/src/components/ui/table/Table.tsx b/src/components/ui/table/Table.tsx
new file mode 100644
index 0000000..ad1016d
--- /dev/null
+++ b/src/components/ui/table/Table.tsx
@@ -0,0 +1,68 @@
+import { Pagination } from '@/components/ui';
+import { TableRowProps } from '@/components/ui/table/TableRowProps';
+import { cn } from '@/lib/utils/cn';
+import { UserRole } from '@/types/user';
+import TableRow from './TableRow';
+
+interface TableProps {
+ tableData: TableRowProps[];
+ userRole: UserRole;
+ headers: string[];
+ total: number;
+ limit: number;
+ offset: number;
+ onPageChange: (newOffset: number) => void;
+}
+
+export default function Table({
+ tableData,
+ headers,
+ userRole,
+ total,
+ limit,
+ offset,
+ onPageChange,
+}: TableProps) {
+ return (
+
+
+
+ {userRole === 'employer' ? '신청자 목록' : '신청 내역'}
+
+
+
+
+
+
+
+ {headers.map((header, index) => (
+ | 0 && index < headers.length - 1 && 'w-[245px]',
+ index === headers.length - 1 && 'w-[220px] md:w-[230px]'
+ )}
+ >
+ {header}
+ |
+ ))}
+
+
+
+ {tableData.map(row => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/table/TableRow.tsx b/src/components/ui/table/TableRow.tsx
new file mode 100644
index 0000000..af17530
--- /dev/null
+++ b/src/components/ui/table/TableRow.tsx
@@ -0,0 +1,55 @@
+import { StatusBadge } from '@/components/ui/badge';
+import { StatusType } from '@/components/ui/badge/StatusBadge';
+import { TableRowProps } from '@/components/ui/table/TableRowProps';
+import { cn } from '@/lib/utils/cn';
+import { getTime } from '@/lib/utils/dateFormatter';
+import { UserRole } from '@/types/user';
+import { useState } from 'react';
+
+interface TableTypeVariant {
+ rowData: TableRowProps;
+ userRole: UserRole;
+}
+
+const TD_BASE = 'border-b border-r px-3 py-5 text-base gap-3 md:border-r-0';
+const TD_STATUS = 'border-b px-2 py-[9px]';
+
+export default function TableRow({ rowData, userRole: userRole }: TableTypeVariant) {
+ const { date, startTime, endTime, duration } = getTime(rowData.startsAt, rowData.workhour);
+ const [status, setStatus] = useState(rowData.status as StatusType);
+
+ const handleStatusChange = (id: string, newStatus: StatusType) => {
+ setStatus(newStatus);
+ };
+
+ const handleApprove = () => setStatus('accepted');
+ const handleReject = () => setStatus('rejected');
+
+ return (
+
+ | {rowData.name} |
+
+ {userRole === 'employee' ? (
+ <>
+ {`${date} ${startTime} ~ ${date} ${endTime} (${duration})`} |
+ {rowData.hourlyPay} |
+ >
+ ) : (
+ <>
+ {rowData.bio} |
+ {rowData.phone} |
+ >
+ )}
+
+
+ |
+
+ );
+}
diff --git a/src/components/ui/table/TableRowProps.tsx b/src/components/ui/table/TableRowProps.tsx
new file mode 100644
index 0000000..5375c61
--- /dev/null
+++ b/src/components/ui/table/TableRowProps.tsx
@@ -0,0 +1,10 @@
+export type TableRowProps = {
+ id: string;
+ name: string;
+ startsAt: string;
+ workhour: number;
+ hourlyPay: string;
+ status: string | JSX.Element;
+ bio: string;
+ phone: string;
+};
diff --git a/src/components/ui/table/index.tsx b/src/components/ui/table/index.tsx
new file mode 100644
index 0000000..e8a4ac3
--- /dev/null
+++ b/src/components/ui/table/index.tsx
@@ -0,0 +1 @@
+export { default as Table } from '@/components/ui/table/Table';
diff --git a/src/constants/dropdown.ts b/src/constants/dropdown.ts
new file mode 100644
index 0000000..fb21d21
--- /dev/null
+++ b/src/constants/dropdown.ts
@@ -0,0 +1,43 @@
+export const ADDRESS_CODE = [
+ '서울시 강남구',
+ '서울시 강동구',
+ '서울시 강북구',
+ '서울시 강서구',
+ '서울시 관악구',
+ '서울시 광진구',
+ '서울시 구로구',
+ '서울시 금천구',
+ '서울시 노원구',
+ '서울시 도봉구',
+ '서울시 동대문구',
+ '서울시 동작구',
+ '서울시 마포구',
+ '서울시 서대문구',
+ '서울시 서초구',
+ '서울시 성동구',
+ '서울시 성북구',
+ '서울시 송파구',
+ '서울시 양천구',
+ '서울시 영등포구',
+ '서울시 용산구',
+ '서울시 은평구',
+ '서울시 종로구',
+ '서울시 중구',
+ '서울시 중랑구',
+] as const;
+export type AddressCode = (typeof ADDRESS_CODE)[number];
+
+export const CATEGORY_CODE = [
+ '한식',
+ '중식',
+ '일식',
+ '양식',
+ '분식',
+ '카페',
+ '편의점',
+ '기타',
+] as const;
+export type CategoryCode = (typeof CATEGORY_CODE)[number];
+
+export const SORT_CODE = ['마감 임박 순', '시급 많은 순', '시간 적은 순', '가나다 순'] as const;
+export type SortCode = (typeof SORT_CODE)[number];
diff --git a/src/constants/icon.ts b/src/constants/icon.ts
index 6d09dd9..07db1e1 100644
--- a/src/constants/icon.ts
+++ b/src/constants/icon.ts
@@ -1,22 +1,26 @@
import arrowUp from '@/assets/icon/ic-arrow-up.svg';
import calendar from '@/assets/icon/ic-calendar.svg';
+import calendarClock from '@/assets/icon/ic-calendar-clock.svg'
import camera from '@/assets/icon/ic-camera.svg';
import checked from '@/assets/icon/ic-checked.svg';
import chevronLeft from '@/assets/icon/ic-chevron-left.svg';
import chevronRight from '@/assets/icon/ic-chevron-right.svg';
import clock from '@/assets/icon/ic-clock.svg';
import close from '@/assets/icon/ic-close.svg';
+import coins from '@/assets/icon/ic-coins.svg';
import dropdownDown from '@/assets/icon/ic-dropdown-down.svg';
import dropdownUp from '@/assets/icon/ic-dropdown-up.svg';
import envelope from '@/assets/icon/ic-envelope.svg';
import facebook from '@/assets/icon/ic-facebook.svg';
import instagram from '@/assets/icon/ic-instagram.svg';
import map from '@/assets/icon/ic-map.svg';
+import mapPin from '@/assets/icon/ic-map-pin.svg';
import notificationOff from '@/assets/icon/ic-notification-off.svg';
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';
@@ -24,20 +28,24 @@ 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,
+ calendarClock: calendarClock.src,
camera: camera.src,
checked: checked.src,
chevronLeft: chevronLeft.src,
chevronRight: chevronRight.src,
clock: clock.src,
close: close.src,
+ coins: coins.src,
dropdownDown: dropdownDown.src,
dropdownUp: dropdownUp.src,
envelope: envelope.src,
facebook: facebook.src,
instagram: instagram.src,
map: map.src,
+ mapPin: mapPin.src,
notificationOff: notificationOff.src,
notificationOn: notificationOn.src,
phone: phone.src,
@@ -53,6 +61,7 @@ export const ICONS = {
export type IconName = keyof typeof ICONS;
export const ICON_SIZES = {
+ 'x-sm': 'w-2.5 h-2.5',
sm: 'w-4 h-4',
rg: 'w-5 h-5',
md: 'w-6 h-6',
@@ -60,3 +69,13 @@ export const ICON_SIZES = {
} as const;
export type IconSize = keyof typeof ICON_SIZES;
+
+export const ICON_RESPONSIVE_SIZES = {
+ 'x-sm': 'tablet:w-2.5 tablet:h-2.5',
+ sm: 'tablet:w-4 tablet:h-4',
+ rg: 'tablet:w-5 tablet:h-5',
+ md: 'tablet:w-6 tablet:h-6',
+ lg: 'tablet:w-8 tablet:h-8',
+} as const;
+
+export type IconResponsiveSize = keyof typeof ICON_RESPONSIVE_SIZES;
diff --git a/src/context/appProviderWrapper.tsx b/src/context/appProviderWrapper.tsx
new file mode 100644
index 0000000..3839620
--- /dev/null
+++ b/src/context/appProviderWrapper.tsx
@@ -0,0 +1,15 @@
+import { ReactNode } from 'react';
+import AuthProvider from './authProvider';
+import ToastProvider from './toastContext';
+import { UserApplicationsProvider } from './userApplicationsProvider';
+
+const AppProviderWrapper = ({ children }: { children: ReactNode }) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+export default AppProviderWrapper;
diff --git a/src/context/authProvider.tsx b/src/context/authProvider.tsx
new file mode 100644
index 0000000..2c13bd7
--- /dev/null
+++ b/src/context/authProvider.tsx
@@ -0,0 +1,177 @@
+// 원칙: state는 user 하나만 관리(부트스트랩/로그인여부는 파생)
+
+import { apiLogin, apiSignup } from '@/api/auth';
+import { apiGetUser, apiUpdateUser } from '@/api/users';
+import type { LoginRequest, User, UserRequest, UserRole } from '@/types/user';
+import { useRouter } from 'next/router';
+import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react';
+
+type AuthContextValue = {
+ /** 파생: user가 있으면 true */
+ isLogin: boolean;
+ /** 파생: user?.type 또는 'guest' */
+ role: UserRole;
+ /** 파생: user !== undefined (부트스트랩 완료 여부) */
+ bootstrapped: boolean;
+ /** 로그인 유저. 미로그인은 null */
+ user: User | null;
+
+ /** 로그인: 토큰/아이디/만료시각 저장 → 내 정보 조회 → user 채움 */
+ login: (credentials: LoginRequest) => Promise;
+ /** 로그아웃: 저장소 초기화 + user=null + (옵션) 리다이렉트 */
+ logout: (redirectTo?: string | false) => void;
+ /** 회원가입 */
+ signup: (data: UserRequest) => Promise;
+ /** 내 정보 재조회: 저장소 userId 기준 */
+ getUser: () => Promise;
+ /** 내 정보 수정: 성공 시 Context의 user 동기화 */
+ updateUser: (patch: Partial) => Promise;
+};
+
+export const AuthContext = createContext(null);
+
+/** LocalStorage keys */
+const TOKEN_KEY = 'thejulge_token';
+const USER_ID_KEY = 'thejulge_user_id';
+const EXPIRES_KEY = 'thejulge_expires_at';
+const EXPIRES_DURATION_MS = 10 * 60 * 1000; // 10분
+
+/** storage helpers */
+const isBrowser = () => typeof window !== 'undefined';
+
+const setLocalStorageItem = (key: string, value: string) => {
+ if (isBrowser()) localStorage.setItem(key, value);
+};
+const getLocalStorageItem = (key: string) => (isBrowser() ? localStorage.getItem(key) : null);
+const removeLocalStorageItem = (key: string) => {
+ if (isBrowser()) localStorage.removeItem(key);
+};
+
+const readAuthFromStorage = () => {
+ const token = getLocalStorageItem(TOKEN_KEY);
+ const userId = getLocalStorageItem(USER_ID_KEY);
+ const expiresAt = Number(getLocalStorageItem(EXPIRES_KEY) ?? '') || 0;
+ return { token, userId, expiresAt };
+};
+
+const AuthProvider = ({ children }: { children: ReactNode }) => {
+ const router = useRouter();
+
+ // 단일 핵심 상태
+ const [user, setUser] = useState(undefined);
+
+ // 파생값
+ const isLogin = !!user;
+ const role: UserRole = user ? user.type : 'guest';
+ const bootstrapped = user !== undefined;
+
+ /** 로그아웃: 저장소 초기화 + user=null + (옵션) 리다이렉트 */
+ const logout = useCallback(
+ (redirectTo: string | false = '/') => {
+ setUser(null);
+ removeLocalStorageItem(TOKEN_KEY);
+ removeLocalStorageItem(USER_ID_KEY);
+ removeLocalStorageItem(EXPIRES_KEY);
+ if (redirectTo !== false) router.replace(redirectTo);
+ },
+ [router]
+ );
+
+ /** 부트스트랩: 저장소 값 유효 → /users/{id} 조회 → user 주입 (아니면 user=null) */
+ useEffect(() => {
+ const bootstrap = async () => {
+ const { token, userId, expiresAt } = readAuthFromStorage();
+
+ // token / userId 없거나 "만료된 expiresAt"만 진짜 무효
+ const isInvalid = !token || !userId || (!!expiresAt && Date.now() >= expiresAt);
+ if (isInvalid) {
+ logout(false); // 화면 이동은 하지 않음
+ setUser(null); // 부트스트랩 종료(비로그인 상태 확정)
+ return;
+ }
+
+ // ✅ 자기치유: token + userId는 있는데 expiresAt만 없는 과거 세션 방어
+ if (!expiresAt) {
+ const newExpiresAt = Date.now() + EXPIRES_DURATION_MS;
+ setLocalStorageItem(EXPIRES_KEY, String(newExpiresAt));
+ }
+
+ try {
+ const me = await apiGetUser(userId);
+ setUser(me);
+ } catch {
+ logout(false);
+ setUser(null);
+ }
+ };
+
+ bootstrap();
+ }, [logout]);
+
+ /** 로그인: 토큰/아이디/만료시각 저장 → 내 정보 조회 → user 채움 */
+ const login = useCallback(async (credentials: LoginRequest) => {
+ const res = await apiLogin(credentials);
+ const token = res.item.token;
+ const userId = res.item.user.item.id;
+ const expiresAt = Date.now() + EXPIRES_DURATION_MS;
+
+ setLocalStorageItem(TOKEN_KEY, token);
+ setLocalStorageItem(USER_ID_KEY, userId);
+ setLocalStorageItem(EXPIRES_KEY, String(expiresAt));
+
+ const me = await apiGetUser(userId);
+ setUser(me);
+ }, []);
+
+ /** 회원가입 */
+ const signup = useCallback(async (data: UserRequest) => {
+ await apiSignup(data);
+ }, []);
+
+ /** 내 정보 재조회: 저장소 userId 기준 */
+ const getUser = useCallback(async () => {
+ const { userId } = readAuthFromStorage();
+ if (!userId) throw new Error('로그인이 필요합니다.');
+ const me = await apiGetUser(userId);
+ setUser(me);
+ }, []);
+
+ /** 내 정보 수정: 성공 시 Context의 user 동기화 */
+ const updateUser = useCallback(async (patch: Partial) => {
+ const { userId } = readAuthFromStorage();
+ if (!userId) throw new Error('로그인이 필요합니다.');
+ const updated = await apiUpdateUser(userId, patch);
+ setUser(updated);
+ }, []);
+
+ /** 만료 체크: 1분마다 확인 → 만료 시 자동 로그아웃
+ * - 로그인 상태에서만 타이머 동작
+ * - expiresAt이 없으면 아무 것도 하지 않음(로그아웃 금지)
+ */
+ useEffect(() => {
+ if (!isLogin) return;
+ const timerId = setInterval(() => {
+ const { expiresAt } = readAuthFromStorage();
+ if (!expiresAt) return; // 키가 없다면 건드리지 않음
+ if (Date.now() >= expiresAt) logout('/'); // 진짜 만료시에만 로그아웃
+ }, 60 * 1000);
+ return () => clearInterval(timerId);
+ }, [isLogin, logout]);
+
+ /** Context 값 */
+ const value: AuthContextValue = {
+ isLogin,
+ role,
+ bootstrapped,
+ user: user ?? null,
+ login,
+ logout,
+ signup,
+ getUser,
+ updateUser,
+ };
+
+ return {children};
+};
+
+export default AuthProvider;
diff --git a/src/context/index.ts b/src/context/index.ts
deleted file mode 100644
index e69de29..0000000
diff --git a/src/context/toastContext.tsx b/src/context/toastContext.tsx
new file mode 100644
index 0000000..02dd415
--- /dev/null
+++ b/src/context/toastContext.tsx
@@ -0,0 +1,58 @@
+import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+interface ToastContextType {
+ showToast: (message: string) => void;
+}
+
+const ToastContext = createContext(null);
+
+export const useToast = () => {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error('useToast 는 Provider 안에서 사용해주세요');
+ return ctx;
+};
+
+const ToastProvider = ({ children }: { children: ReactNode }) => {
+ const [message, setMessage] = useState(null);
+
+ const showToast = (msg: string) => {
+ setMessage(msg);
+ };
+
+ useEffect(() => {
+ if (!message) return;
+
+ const timer = setTimeout(() => {
+ setMessage(null);
+ }, 3000);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ });
+
+ const toastRoot = typeof window !== 'undefined' ? document.getElementById('toast-root') : null;
+
+ return (
+ <>
+
+ {children}
+ {toastRoot &&
+ createPortal(
+ message ? (
+
+ {message}
+
+ ) : null,
+ toastRoot
+ )}
+
+ >
+ );
+};
+
+export default ToastProvider;
diff --git a/src/context/userApplicationsProvider.tsx b/src/context/userApplicationsProvider.tsx
new file mode 100644
index 0000000..157f930
--- /dev/null
+++ b/src/context/userApplicationsProvider.tsx
@@ -0,0 +1,147 @@
+// context/UserApplicationsProvider.tsx
+import { getAllUserApplications, postApplication, putApplication } from '@/api/applications';
+import useAuth from '@/hooks/useAuth';
+import { ApiResponse } from '@/types/api';
+import { ApplicationItem, ApplicationStatus } from '@/types/applications';
+import {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+
+interface UserApplicationsContextValue {
+ applications: ApiResponse[];
+ isLoading: boolean;
+ error: string | null;
+ isApplied: (noticeId: string) => boolean; // 특정 공고 지원 여부
+ applicationStatus: (noticeId: string) => ApplicationStatus | null; // 특정 공고의 지원 상태
+ applyNotice: (shopId: string, noticeId: string) => Promise; // 공고 지원
+ cancelNotice: (noticeId: string) => Promise; // 공고 취소
+ refresh: () => Promise; // 전체 새로고침(fetch)
+}
+
+const UserApplicationsContext = createContext(null);
+
+// 유저 공고 조회, 신청, 취소 (마이페이지, 상단 알림, 공고 상세 동일한 데이터 사용)
+export const UserApplicationsProvider = ({ children }: { children: ReactNode }) => {
+ const { user } = useAuth();
+ const [applications, setApplications] = useState[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 전체 신청 내역 불러오기
+ const fetchAllApplications = useCallback(async () => {
+ if (!user?.id) return;
+ try {
+ setIsLoading(true);
+ setError(null);
+ const all = await getAllUserApplications({ userId: user.id, limit: 50 });
+ setApplications(all);
+ } catch {
+ setError(`신청 내역을 불러오지 못했습니다`);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [user]);
+
+ // 로그인 유저 변경 시 fetch
+ useEffect(() => {
+ if (user) fetchAllApplications();
+ else setApplications([]);
+ }, [user, fetchAllApplications]);
+
+ // 특정 공고 지원 여부
+ const isApplied = useCallback(
+ (noticeId: string) =>
+ applications.some(
+ app =>
+ app.item.notice.item.id === noticeId &&
+ (app.item.status === 'pending' || app.item.status === 'accepted')
+ ),
+ [applications]
+ );
+
+ // 특정 공고의 지원 상태 반환
+ const applicationStatus = useCallback(
+ (noticeId: string): ApplicationStatus | null => {
+ const found = applications.find(app => app.item.notice.item.id === noticeId);
+ return found ? found.item.status : null;
+ },
+ [applications]
+ );
+
+ // 특정 공고 지원
+ const applyNotice = useCallback(
+ async (shopId: string, noticeId: string) => {
+ if (!user?.id) {
+ setError('로그인이 필요합니다.');
+ return;
+ }
+ await postApplication(shopId, noticeId);
+ await fetchAllApplications(); // 최신화 반영
+ },
+ [user, fetchAllApplications]
+ );
+
+ // 특정 공고 지원 취소
+ const cancelNotice = useCallback(
+ async (noticeId: string) => {
+ if (!user?.id) {
+ setError('로그인이 필요합니다');
+ return;
+ }
+
+ const target = applications.find(app => app.item.notice.item.id === noticeId);
+
+ if (!target) {
+ setError('신청 내역을 찾을 수 없습니다');
+ return;
+ }
+ const shopId = target.item.shop.item.id;
+ const applicationId = target.item.id;
+
+ await putApplication(shopId, noticeId, applicationId);
+ await fetchAllApplications(); // 최신화 반영
+ },
+ [applications, user, fetchAllApplications]
+ );
+
+ const value = useMemo(
+ () => ({
+ applications,
+ isLoading,
+ error,
+ isApplied,
+ applicationStatus,
+ applyNotice,
+ cancelNotice,
+ refresh: fetchAllApplications,
+ }),
+ [
+ applications,
+ isLoading,
+ error,
+ isApplied,
+ applicationStatus,
+ applyNotice,
+ cancelNotice,
+ fetchAllApplications,
+ ]
+ );
+
+ return (
+ {children}
+ );
+};
+
+export const useUserApplications = () => {
+ const context = useContext(UserApplicationsContext);
+ if (!context) {
+ throw new Error('useUserApplications는 Provider 안에서 사용해야 합니다.');
+ }
+ return context;
+};
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
deleted file mode 100644
index e69de29..0000000
diff --git a/src/hooks/useAsync.ts b/src/hooks/useAsync.ts
new file mode 100644
index 0000000..d48f1cd
--- /dev/null
+++ b/src/hooks/useAsync.ts
@@ -0,0 +1,40 @@
+import { ApiAsync } from '@/types/api';
+import { useCallback, useState } from 'react';
+
+interface UseAsyncState extends ApiAsync{
+ data: T | null;
+ fetch: (promise: Promise) => Promise;
+ reset: () => void;
+}
+
+const useAsync = (): UseAsyncState => {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ const fetch = useCallback(async (promise: Promise) => {
+ try {
+ setIsInitialized(true);
+ setIsLoading(true);
+ setError(null);
+ const result = await promise;
+ setData(result);
+ return result;
+ } catch (err) {
+ setError(`요청 중 오류가 발생했습니다 : ${err}`);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ const reset = useCallback(() => {
+ setData(null);
+ setError(null);
+ setIsLoading(false);
+ setIsInitialized(false);
+ }, []);
+
+ return { data, isLoading, isInitialized, error, fetch, reset };
+};
+export default useAsync;
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
new file mode 100644
index 0000000..d1962d7
--- /dev/null
+++ b/src/hooks/useAuth.ts
@@ -0,0 +1,9 @@
+import { AuthContext } from '@/context/authProvider';
+import { useContext } from 'react';
+
+const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (!context) throw new Error('useAuth는 AuthProvider 안에서 사용해야 합니다.');
+ return context;
+};
+export default useAuth;
diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts
new file mode 100644
index 0000000..fbeccf6
--- /dev/null
+++ b/src/hooks/useClickOutside.ts
@@ -0,0 +1,26 @@
+import { useEffect } from 'react';
+
+// @example : useClickOutside(dropdownRef, setClose);
+type ClickOutsideHandler = (e: MouseEvent | TouchEvent) => void;
+
+const useClickOutside = (
+ ref: React.RefObject,
+ handler: ClickOutsideHandler
+): void => {
+ useEffect(() => {
+ const listener: ClickOutsideHandler = e => {
+ if (!ref.current || ref.current.contains(e.target as Node)) return;
+ handler(e);
+ };
+
+ document.addEventListener('mousedown', listener);
+ document.addEventListener('touchstart', listener);
+
+ return () => {
+ document.removeEventListener('mousedown', listener);
+ document.removeEventListener('touchstart', listener);
+ };
+ }, [ref, handler]);
+};
+
+export default useClickOutside;
diff --git a/src/hooks/useEscapeKey.ts b/src/hooks/useEscapeKey.ts
new file mode 100644
index 0000000..ff8fc9c
--- /dev/null
+++ b/src/hooks/useEscapeKey.ts
@@ -0,0 +1,18 @@
+import { useEffect } from 'react';
+
+// @example : useEscapeKey(setClose);
+type EscapeHandler = (e: KeyboardEvent) => void;
+
+const useEscapeKey = (handler: EscapeHandler) => {
+ useEffect(() => {
+ const listener: EscapeHandler = e => {
+ if (e.key !== 'Escape') return;
+ handler(e);
+ };
+
+ document.addEventListener('keydown', listener);
+ return () => document.removeEventListener('keydown', listener);
+ }, [handler]);
+};
+
+export default useEscapeKey;
diff --git a/src/hooks/useSafeRef.ts b/src/hooks/useSafeRef.ts
new file mode 100644
index 0000000..7d34e64
--- /dev/null
+++ b/src/hooks/useSafeRef.ts
@@ -0,0 +1,14 @@
+import { useCallback, useRef } from 'react';
+
+// 안전하게 DOM 접근을 위한 hook
+// DOM이 없어질 수 있는 타이밍(언마운트, 조건부 렌더링 등)을 대비해서 존재 여부 확인
+const useSafeRef = () => {
+ const ref = useRef(null);
+
+ const setRef = useCallback((node: T | null) => {
+ ref.current = node;
+ }, []);
+
+ return [setRef, ref] as const;
+};
+export default useSafeRef;
diff --git a/src/hooks/useToggle.ts b/src/hooks/useToggle.ts
new file mode 100644
index 0000000..2f3de3e
--- /dev/null
+++ b/src/hooks/useToggle.ts
@@ -0,0 +1,17 @@
+import { useCallback, useState } from 'react';
+
+interface UseToggle {
+ isOpen: boolean;
+ toggle: () => void;
+ setOpen: () => void;
+ setClose: () => void;
+}
+
+const useToggle = (init = false): UseToggle => {
+ const [isOpen, setIsOpen] = useState(init);
+ const toggle = useCallback(() => setIsOpen(prev => !prev), []);
+ const setOpen = useCallback(() => setIsOpen(true), []);
+ const setClose = useCallback(() => setIsOpen(false), []);
+ return { isOpen, toggle, setOpen, setClose };
+};
+export default useToggle;
diff --git a/src/lib/axios/index.ts b/src/lib/axios/index.ts
index 68af766..2e4cf72 100644
--- a/src/lib/axios/index.ts
+++ b/src/lib/axios/index.ts
@@ -1,6 +1,34 @@
-import baseAxios from 'axios';
+import axios, { AxiosHeaders, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
-const axiosInstance = baseAxios.create({
+const api: AxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
+ headers: new AxiosHeaders({ 'Content-Type': 'application/json' }), // 타입 안전
});
-export default axiosInstance;
+
+// "토큰을 붙이지 않을" 요청인지 판단
+// - 쿼리스트링이 있어도 경로 부분만 비교한다.
+function isAuthFree(config: InternalAxiosRequestConfig) {
+ const method = (config.method || 'get').toLowerCase();
+ const url = String(config.url || '');
+ const pathOnly = url.split('?')[0]; // '/token?x=1' → '/token'
+ const isLoginOrSignup = pathOnly.endsWith('/token') || pathOnly.endsWith('/users');
+ return method === 'post' && isLoginOrSignup;
+}
+
+api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
+ // 브라우저가 아니면(SSR) 스킵
+ if (typeof window === 'undefined') return config;
+
+ // 로그인/회원가입 요청이면 스킵
+ if (isAuthFree(config)) return config;
+
+ // localStorage에서 토큰을 읽어 Authorization에 붙이기
+ const token = localStorage.getItem('thejulge_token');
+ if (token) {
+ // headers는 AxiosHeaders 타입이므로 set()을 안전하게 쓸 수 있다.
+ (config.headers as AxiosHeaders).set('Authorization', `Bearer ${token}`);
+ }
+ return config;
+});
+
+export default api;
diff --git a/src/lib/utils/calcPayIncrease.ts b/src/lib/utils/calcPayIncrease.ts
new file mode 100644
index 0000000..5607766
--- /dev/null
+++ b/src/lib/utils/calcPayIncrease.ts
@@ -0,0 +1,6 @@
+export const calcPayIncreasePercent = (hourlyPay: number, originalHourlyPay: number) => {
+ if (!originalHourlyPay) return null;
+ const percent = Math.floor(((hourlyPay - originalHourlyPay) / originalHourlyPay) * 100);
+
+ return percent > 0 ? percent : null;
+};
diff --git a/src/lib/utils/cn.ts b/src/lib/utils/cn.ts
index 2796062..693481d 100644
--- a/src/lib/utils/cn.ts
+++ b/src/lib/utils/cn.ts
@@ -1,11 +1,33 @@
import { clsx, type ClassValue } from 'clsx';
-import { twMerge } from 'tailwind-merge';
+import { extendTailwindMerge } from 'tailwind-merge';
/**
* Tailwind 클래스 이름을 안전하게 합쳐주는 함수
* - clsx: 조건부 class 병합
* - twMerge: Tailwind 규칙 기반 충돌 해결
* @example
*/
+
+const twMergeCustom = extendTailwindMerge({
+ extend: {
+ classGroups: {
+ 'font-size': [
+ {
+ text: [
+ 'caption',
+ 'modal',
+ 'body-s',
+ 'body-m',
+ 'body-l',
+ 'heading-s',
+ 'heading-m',
+ 'heading-l',
+ ],
+ },
+ ],
+ },
+ },
+});
+
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
+ return twMergeCustom(clsx(inputs));
}
diff --git a/src/lib/utils/dateFormatter.ts b/src/lib/utils/dateFormatter.ts
new file mode 100644
index 0000000..6553766
--- /dev/null
+++ b/src/lib/utils/dateFormatter.ts
@@ -0,0 +1,53 @@
+export function formatDate(date: Date): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+
+ return `${year}.${month}.${day}`;
+}
+
+export function formatTime(date: Date): string {
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+
+ return `${hours}:${minutes}`;
+}
+
+export function formatDateTime(date: Date | null): string {
+ return date ? `${formatDate(date)} ${formatTime(date)}` : '';
+}
+
+export function getTime(startsAt: string, workhour: number) {
+ const startDate = new Date(startsAt);
+ const endDate = new Date(startsAt);
+
+ endDate.setHours(endDate.getHours() + workhour);
+
+ return {
+ date: formatDate(startDate),
+ startTime: formatTime(startDate),
+ endTime: formatTime(endDate),
+ duration: `${workhour}시간`,
+ };
+}
+
+export function formatWithDots(numbers: string) {
+ const year = numbers.slice(0, 4);
+ const month = numbers.slice(4, 6);
+ const day = numbers.slice(6, 8);
+
+ if (month && day) return `${year}.${month}.${day}`;
+ if (month) return `${year}.${month}`;
+ if (year) return `${year}`;
+ return numbers;
+}
+
+export function parseRFC3339(v?: string | null): Date | null {
+ if (!v) return null;
+ const t = Date.parse(v);
+ return isNaN(t) ? null : new Date(t);
+}
+
+export function toRFC3339(d: Date | null | undefined): string | undefined {
+ return d ? d.toISOString() : undefined;
+}
diff --git a/src/lib/utils/fillCalendarDays.ts b/src/lib/utils/fillCalendarDays.ts
new file mode 100644
index 0000000..ac88e7f
--- /dev/null
+++ b/src/lib/utils/fillCalendarDays.ts
@@ -0,0 +1,36 @@
+import { CalendarDay } from '@/types/calendar';
+
+export const fillCalendarDays = (year: number, month: number) => {
+ const days: CalendarDay[] = [];
+
+ const firstDayOfMonth = new Date(year, month, 1);
+ const fisrtDay = firstDayOfMonth.getDay(); // 첫 날 요일
+ const prevLastDate = new Date(year, month, 0); // 마지막 날짜 (month: 0 -> 12/31, 1 -> 1/31...)
+ const prevMonthLast = prevLastDate.getDate(); // 마지막달 일자
+
+ // 이전 달 날짜 채우기(일요일 시작 달력 기준)
+ // ex) 만약 firstDay = 2(화요일) / prevMonthLast = 30
+ // => 지난 달 날짜 2번(일, 월)
+ // 30 - 2 + 1 + i -> 29, 30 채워 넣음
+ for (let i = 0; i < fisrtDay; i++) {
+ days.push({
+ date: new Date(year, month - 1, prevMonthLast - fisrtDay + 1 + i),
+ isCurrentMonth: false,
+ });
+ }
+
+ // 이번 달 날짜 채우기
+ const lastDate = new Date(year, month + 1, 0).getDate();
+ for (let i = 1; i <= lastDate; i++) {
+ days.push({ date: new Date(year, month, i), isCurrentMonth: true });
+ }
+
+ // 다음 달 날짜 채우기
+ const totalCells = Math.ceil(days.length / 7) * 7;
+ const nextMonthDayCount = totalCells - days.length;
+ for (let i = 1; i <= nextMonthDayCount; i++) {
+ days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
+ }
+
+ return days;
+};
diff --git a/src/lib/utils/formatNumber.ts b/src/lib/utils/formatNumber.ts
new file mode 100644
index 0000000..3156d4f
--- /dev/null
+++ b/src/lib/utils/formatNumber.ts
@@ -0,0 +1 @@
+export const formatNumber = (number: number) => number.toLocaleString();
diff --git a/src/lib/utils/getNoticeStatus.ts b/src/lib/utils/getNoticeStatus.ts
new file mode 100644
index 0000000..994013e
--- /dev/null
+++ b/src/lib/utils/getNoticeStatus.ts
@@ -0,0 +1,7 @@
+type NoticeStatus = 'open' | 'expired' | 'closed';
+export const hasShiftStarted = (startsAt: string) => Date.now() >= new Date(startsAt).getTime();
+
+export const getNoticeStatus = (closed: boolean, startsAt: string): NoticeStatus => {
+ if (closed) return 'closed';
+ return hasShiftStarted(startsAt) ? 'expired' : 'open';
+};
diff --git a/src/lib/utils/paramsSerializer.ts b/src/lib/utils/paramsSerializer.ts
new file mode 100644
index 0000000..a99279c
--- /dev/null
+++ b/src/lib/utils/paramsSerializer.ts
@@ -0,0 +1,13 @@
+export function paramsSerializer(params: Record): string {
+ const usp = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined || value === null || value === '') continue;
+ if (Array.isArray(value)) {
+ // address=강남구&address=서초구 형식으로 변경
+ for (const v of value) usp.append(key, String(v));
+ } else {
+ usp.append(key, String(value));
+ }
+ }
+ return usp.toString();
+}
diff --git a/src/lib/utils/parse.ts b/src/lib/utils/parse.ts
new file mode 100644
index 0000000..70c4789
--- /dev/null
+++ b/src/lib/utils/parse.ts
@@ -0,0 +1,40 @@
+import { type NoticeCard, NoticeItemResponse, PostCard } from '@/types/notice';
+
+export const toPostCard = (res: NoticeItemResponse): PostCard => {
+ const n = res.item;
+ const shop = n.shop.item;
+
+ return {
+ id: n.id,
+ hourlyPay: n.hourlyPay,
+ startsAt: n.startsAt,
+ workhour: n.workhour,
+ closed: n.closed,
+ shopId: shop.id,
+ name: shop.name,
+ address1: shop.address1,
+ imageUrl: shop.imageUrl,
+ originalHourlyPay: shop.originalHourlyPay,
+ };
+};
+
+export const toNoticeCard = (res: NoticeItemResponse): NoticeCard => {
+ const n = res.item;
+ const shop = n.shop.item;
+
+ return {
+ id: n.id,
+ hourlyPay: n.hourlyPay,
+ startsAt: n.startsAt,
+ workhour: n.workhour,
+ description: n.description,
+ closed: n.closed,
+ shopId: shop.id,
+ name: shop.name,
+ category: shop.category,
+ address1: shop.address1,
+ shopDescription: shop.description,
+ imageUrl: shop.imageUrl,
+ originalHourlyPay: shop.originalHourlyPay,
+ };
+};
diff --git a/src/lib/utils/timeAgo.ts b/src/lib/utils/timeAgo.ts
new file mode 100644
index 0000000..70bb9b7
--- /dev/null
+++ b/src/lib/utils/timeAgo.ts
@@ -0,0 +1,24 @@
+type timeUnit = { seconds: number; label: string };
+
+const TIME_UNITS: timeUnit[] = [
+ { seconds: 365 * 24 * 60 * 60, label: '년' },
+ { seconds: 30 * 24 * 60 * 60, label: '개월' },
+ { seconds: 24 * 60 * 60, label: '일' },
+ { seconds: 60 * 60, label: '시간' },
+ { seconds: 60, label: '분' },
+];
+
+export function timeAgo(dateString: string): string {
+ const NOW = new Date();
+ const DATE = new Date(dateString);
+
+ const DIFF_SECONDS = Math.floor((NOW.getTime() - DATE.getTime()) / 1000);
+
+ for (const UNIT of TIME_UNITS) {
+ const INTERVAL = Math.floor(DIFF_SECONDS / UNIT.seconds);
+ if (INTERVAL >= 1) {
+ return `${INTERVAL}${UNIT.label} 전`;
+ }
+ }
+ return '방금 전';
+}
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
new file mode 100644
index 0000000..9f65bfe
--- /dev/null
+++ b/src/pages/404.tsx
@@ -0,0 +1,34 @@
+import { Container } from '@/components/layout';
+import { Button, Icon } from '@/components/ui';
+import { useRouter } from 'next/router';
+
+export default function NotFound() {
+ const router = useRouter();
+
+ return (
+
+
+
+
+
+ 페이지를 찾을 수 없습니다
+
+ 요청하신 페이지가 삭제되었거나, 주소가 변경되었을 수 있어요.
+ 아래 버튼을 통해 홈으로 이동하거나 공고를 다시 찾아보세요.
+
+
+
+
+ );
+}
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 81abd54..f66848d 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -1,7 +1,35 @@
-import '@/styles/globals.css';
+import { Footer, Header, Wrapper } from '@/components/layout';
+import AppProviderWrapper from '@/context/appProviderWrapper';
import '@/styles/fonts.css';
+import '@/styles/globals.css';
+import type { NextPage } from 'next';
import type { AppProps } from 'next/app';
+import Head from 'next/head';
+
+export type NextPageWithLayout = NextPage
& {
+ getLayout?: (page: React.ReactNode) => React.ReactNode;
+};
+type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout };
+
+export default function App({ Component, pageProps }: AppPropsWithLayout) {
+ const getLayout =
+ Component.getLayout ??
+ (page => (
+
+
+ {page}
+
+
+ ));
-export default function App({ Component, pageProps }: AppProps) {
- return ;
+ return (
+ <>
+
+ 일일 알바 매칭 플랫폼 | The-julge
+
+
+
+ {getLayout()}
+ >
+ );
}
diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx
index a1fb0d9..214a726 100644
--- a/src/pages/_document.tsx
+++ b/src/pages/_document.tsx
@@ -1,4 +1,4 @@
-import { Html, Head, Main, NextScript } from 'next/document';
+import { Head, Html, Main, NextScript } from 'next/document';
export default function Document() {
return (
@@ -6,6 +6,7 @@ export default function Document() {
+
diff --git a/src/pages/employer/shops/[shopId]/notices/[noticeId]/edit.tsx b/src/pages/employer/shops/[shopId]/notices/[noticeId]/edit.tsx
new file mode 100644
index 0000000..9917ef7
--- /dev/null
+++ b/src/pages/employer/shops/[shopId]/notices/[noticeId]/edit.tsx
@@ -0,0 +1,178 @@
+import { Button, DateInput, Input, Modal, TimeInput } from '@/components/ui';
+import useAuth from '@/hooks/useAuth';
+import axiosInstance from '@/lib/axios';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+
+interface NoticePayload {
+ hourlyPay: number;
+ startsAt: string;
+ workhour: number;
+ description: string;
+}
+
+const EmployerNoticeEditPage = () => {
+ const router = useRouter();
+ const { user } = useAuth();
+ const { noticeId } = router.query;
+
+ const [wage, setWage] = useState('');
+ const [date, setDate] = useState(null);
+ const [time, setTime] = useState(null);
+ const [workhour, setWorkhour] = useState();
+ const [description, setDescription] = useState('');
+
+ const [loading, setLoading] = useState(true);
+ const [modalOpen, setModalOpen] = useState(false);
+
+ useEffect(() => {
+ if (!user) return;
+ if (!user.shop) {
+ alert('접근 권한이 없습니다.');
+ router.replace('/');
+ return;
+ }
+
+ const fetchNotice = async () => {
+ try {
+ const response = await axiosInstance.get(
+ `/shops/${user.shop?.item.id}/notices/${noticeId}`
+ );
+ const notice = response.data.item;
+
+ setWage(notice.hourlyPay.toString());
+ setWorkhour(notice.workhour);
+ setDescription(notice.description);
+
+ const startDate = new Date(notice.startsAt);
+ setDate(startDate);
+ setTime(startDate);
+ } catch {
+ alert('공고 정보를 불러오는 중 오류가 발생했습니다.');
+ router.back();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (noticeId) fetchNotice();
+ }, [noticeId, user, router]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!date || !time || !wage || !workhour || !description) return;
+ if (!user?.shop || !noticeId) return;
+
+ const combinedDateTime = new Date(date);
+ combinedDateTime.setHours(time.getHours(), time.getMinutes(), 0, 0);
+
+ const payload: NoticePayload = {
+ hourlyPay: Number(wage),
+ startsAt: combinedDateTime.toISOString(),
+ workhour,
+ description,
+ };
+
+ try {
+ await axiosInstance.put(`/shops/${user.shop.item.id}/notices/${noticeId}`, payload);
+ setModalOpen(true);
+ } catch (error) {
+ alert(error instanceof Error ? error.message : '공고 수정 중 오류 발생');
+ }
+ };
+
+ const handleModalClose = () => {
+ setModalOpen(false);
+ if (user?.shop) {
+ router.push(`/employer/shops/${user.shop.item.id}/notices/${noticeId}`);
+ }
+ };
+
+ if (loading) return;
+ if (!user?.shop) return null;
+
+ return (
+
+ );
+};
+
+export default EmployerNoticeEditPage;
diff --git a/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx b/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx
new file mode 100644
index 0000000..7e384db
--- /dev/null
+++ b/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx
@@ -0,0 +1,222 @@
+import { Button, Modal, Notice, Table } from '@/components/ui';
+import { TableRowProps } from '@/components/ui/table/TableRowProps';
+import useAuth from '@/hooks/useAuth';
+import axiosInstance from '@/lib/axios';
+import { getNoticeStatus } from '@/lib/utils/getNoticeStatus';
+import { toNoticeCard } from '@/lib/utils/parse';
+import type { NoticeCard } from '@/types/notice';
+import { Shop } from '@/types/shop';
+import { UserRole } from '@/types/user';
+import type { GetServerSideProps } from 'next';
+import { useRouter } from 'next/router';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+interface ModalItems {
+ title?: string;
+ primaryText?: string;
+ secondaryText?: string;
+ onPrimary?: () => void;
+ onSecondary?: () => void;
+}
+interface EditItems extends ModalItems {
+ noShop?: ModalItems;
+ shop?: ModalItems;
+}
+interface ApplicationTableApiResponse {
+ item: {
+ id: string;
+ status: string;
+ user?: {
+ href: string;
+ item: {
+ id: string;
+ name: string;
+ bio: string;
+ phone: string;
+ };
+ };
+ notice?: {
+ item?: {
+ startsAt: string;
+ workhour: number;
+ hourlyPay?: number;
+ };
+ };
+ };
+ links: unknown[];
+}
+
+const EDIT_ITEMS: Record = {
+ guest: {
+ title: '로그인이 필요합니다',
+ primaryText: '로그인하기',
+ secondaryText: '닫기',
+ },
+ employer: {
+ shop: {},
+ noShop: {
+ title: '내 가게를 먼저 등록해주세요',
+ primaryText: '가게 등록',
+ secondaryText: '닫기',
+ },
+ },
+ employee: {
+ title: '접근할 수 없습니다',
+ primaryText: '확인',
+ },
+};
+
+function hasShopFields(user: Shop | null) {
+ if (!user) return false;
+ return Boolean(
+ user.name &&
+ user.category &&
+ user.address1 &&
+ user.address2 &&
+ user.description &&
+ user.imageUrl &&
+ user.originalHourlyPay
+ );
+}
+
+export const getServerSideProps: GetServerSideProps<{ notice: NoticeCard }> = async ({
+ params,
+}) => {
+ const { shopId, noticeId } = params as { shopId: string; noticeId: string };
+ try {
+ const noticeRes = await axiosInstance.get(`shops/${shopId}/notices/${noticeId}`);
+ return { props: { notice: toNoticeCard(noticeRes.data) } };
+ } catch {
+ return {
+ notFound: true,
+ };
+ }
+};
+
+const EmployerNoticeDetailPage = ({ notice }: { notice: NoticeCard }) => {
+ const headers = ['신청자', '소개', '전화번호', '상태'];
+ const [data, setData] = useState([]);
+
+ const [offset, setOffset] = useState(0);
+ const limit = 5;
+
+ const { role, isLogin, user } = useAuth();
+ const router = useRouter();
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modal, setModal] = useState(null);
+
+ const status = getNoticeStatus(notice.closed, notice.startsAt);
+ const isOwner = user?.shop?.item.id === notice.shopId;
+ const canEdit = useMemo(() => status === 'open' && isOwner, [status, isOwner]);
+
+ // 공고 편집하기
+ const handleEditClick = useCallback(() => {
+ if (!canEdit) return;
+
+ if (!isLogin) {
+ const items = EDIT_ITEMS.guest;
+ setModal({
+ ...items,
+ onPrimary: () => router.push('/login'),
+ onSecondary: () => setModalOpen(false),
+ });
+ setModalOpen(true);
+ return;
+ }
+
+ if (role === 'employee') {
+ const items = EDIT_ITEMS.employee;
+ setModal({
+ ...items,
+ onPrimary: () => setModalOpen(false),
+ });
+ setModalOpen(true);
+ return;
+ }
+
+ const hasShop = hasShopFields(user?.shop?.item ?? null);
+ if (!hasShop) {
+ const items = EDIT_ITEMS.employer.noShop;
+ setModal({
+ ...items,
+ onPrimary: () => router.push('/my-shop'),
+ onSecondary: () => setModalOpen(false),
+ });
+ setModalOpen(true);
+ return;
+ }
+
+ router.push(`/employer/shops/${notice.shopId}/notices/${notice.id}/edit`);
+ }, [canEdit, isLogin, role, user, notice, router]);
+
+ // 신청자 불러오기
+
+ useEffect(() => {
+ const fetchApplications = async () => {
+ const res = await axiosInstance.get<{ items: ApplicationTableApiResponse[] }>(
+ `/shops/${notice.shopId}/notices/${notice.id}/applications`,
+ { params: { offset, limit } }
+ );
+
+ const tableData: TableRowProps[] = res.data.items.map(app => {
+ const userItem = app.item.user?.item;
+ const noticeItem = app.item.notice?.item;
+
+ return {
+ id: app.item.id,
+ name: userItem?.name ?? '-',
+ bio: userItem?.bio ?? '-',
+ phone: userItem?.phone ?? '-',
+ startsAt: noticeItem?.startsAt ?? '-',
+ workhour: noticeItem?.workhour ?? 0,
+ hourlyPay: noticeItem?.hourlyPay
+ ? `${noticeItem.hourlyPay.toLocaleString()}원`
+ : '정보 없음',
+ status: app.item.status,
+ };
+ });
+
+ setData(tableData);
+ };
+
+ fetchApplications();
+ }, [notice.shopId, notice.id, offset, limit]);
+
+ return (
+
+
+
+ setModalOpen(false)}
+ variant='warning'
+ title={modal?.title ?? '유저 정보를 확인해주세요'}
+ primaryText={modal?.primaryText ?? '확인'}
+ onPrimary={modal?.onPrimary ?? (() => setModalOpen(false))}
+ secondaryText={modal?.secondaryText}
+ onSecondary={modal?.onSecondary}
+ />
+
+
+
+ );
+};
+
+export default EmployerNoticeDetailPage;
diff --git a/src/pages/employer/shops/[shopId]/notices/register/index.tsx b/src/pages/employer/shops/[shopId]/notices/register/index.tsx
new file mode 100644
index 0000000..3ec25b4
--- /dev/null
+++ b/src/pages/employer/shops/[shopId]/notices/register/index.tsx
@@ -0,0 +1,161 @@
+import { Button, DateInput, Input, Modal, TimeInput } from '@/components/ui';
+import useAuth from '@/hooks/useAuth';
+import axiosInstance from '@/lib/axios';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+interface NoticeLoad {
+ hourlyPay: number;
+ startsAt: string;
+ workhour: number;
+ description: string;
+}
+const EmployerNoticeRegisterPage = () => {
+ const router = useRouter();
+ const { user } = useAuth();
+
+ const [wage, setWage] = useState('');
+ const [date, setDate] = useState(null);
+ const [time, setTime] = useState(null);
+ const [workhour, setWorkhour] = useState();
+ const [description, setDescription] = useState('');
+
+ const [accessModal, setAccessModal] = useState(false);
+ const [successModal, setSuccessModal] = useState(false);
+ const [modalHandler, setModalHandler] = useState<() => void>(() => () => {});
+
+ useEffect(() => {
+ if (user && !user.shop) {
+ setAccessModal(true);
+ }
+ }, [user]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!date || !time || !wage || !workhour || !description) return;
+ if (!user?.shop) return;
+ const combinedDateTime = new Date(date);
+ combinedDateTime.setHours(time.getHours(), time.getMinutes(), 0, 0);
+ const payload: NoticeLoad = {
+ hourlyPay: Number(wage),
+ startsAt: combinedDateTime.toISOString(),
+ workhour,
+ description,
+ };
+
+ try {
+ const response = await axiosInstance.post(`/shops/${user.shop.item.id}/notices`, payload);
+ const noticeId = response.data.item.id;
+
+ if (!noticeId) {
+ alert('등록된 공고 ID를 가져올 수 없습니다.');
+ return;
+ }
+
+ const handleModalConfirm = async () => {
+ setSuccessModal(false);
+ await router.push(`/employer/shops/${user.shop!.item.id}/notices/${noticeId}`);
+ };
+
+ setModalHandler(() => handleModalConfirm);
+ setSuccessModal(true);
+ } catch (error) {
+ alert(error instanceof Error ? error.message : '공고 등록 중 오류 발생');
+ }
+ };
+
+ return (
+
+
공고 등록
+
+
+ {accessModal && (
+
router.replace('/')} // 확인 누르면 메인으로
+ onClose={() => setAccessModal(false)}
+ />
+ )}
+
+ setSuccessModal(false)}
+ title='등록 완료'
+ variant='success'
+ primaryText='확인'
+ onPrimary={modalHandler}
+ />
+
+ );
+};
+export default EmployerNoticeRegisterPage;
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 6ff5373..ae469dc 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -1,3 +1,10 @@
-export default function Home() {
- return <>>;
+import { CustomNotice, NoticeListSection } from '@/components/features';
+
+export default function Main() {
+ return (
+ <>
+
+ ;
+ >
+ );
}
diff --git a/src/pages/login.tsx b/src/pages/login.tsx
new file mode 100644
index 0000000..5185589
--- /dev/null
+++ b/src/pages/login.tsx
@@ -0,0 +1,201 @@
+import logo from '@/assets/images/logo.svg';
+import Button from '@/components/ui/button/button';
+import Input from '@/components/ui/input/input';
+import Modal from '@/components/ui/modal/modal';
+import useAuth from '@/hooks/useAuth';
+import { cn } from '@/lib/utils/cn';
+import Image from 'next/image';
+import Link from 'next/link';
+import { useState, type ReactNode } from 'react';
+import type { NextPageWithLayout } from './_app';
+
+const getMsg = (err: unknown, fallback: string) => {
+ if (typeof err === 'string') return err;
+ if (err && typeof err === 'object') {
+ const e = err as {
+ response?: { status?: number; data?: { message?: string } };
+ message?: string;
+ };
+ return e.response?.data?.message ?? e.message ?? fallback;
+ }
+ return fallback;
+};
+
+const LoginPage: NextPageWithLayout = () => {
+ const { login } = useAuth();
+
+ const [email, setEmail] = useState('');
+ const [pw, setPw] = useState('');
+
+ const [emailErr, setEmailErr] = useState(null);
+ const [pwErr, setPwErr] = useState(null);
+
+ const [loading, setLoading] = useState(false);
+ const [failOpen, setFailOpen] = useState(false);
+ const [globalErr, setGlobalErr] = useState(null);
+
+ const onBlurEmail = (e: React.FocusEvent) => {
+ if (e.currentTarget.validity.typeMismatch) setEmailErr('이메일 형식으로 작성해 주세요.');
+ else setEmailErr(null);
+ };
+ const onBlurPw = () => setPwErr(pw.length < 8 ? '8자 이상 입력해주세요.' : null);
+
+ const canSubmit = !!email && pw.length >= 8 && !emailErr && !pwErr && !loading;
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setGlobalErr(null);
+
+ if (!e.currentTarget.checkValidity()) {
+ e.currentTarget.reportValidity();
+ return;
+ }
+ if (!canSubmit) {
+ onBlurEmail({
+ currentTarget: e.currentTarget.email,
+ } as unknown as React.FocusEvent);
+ onBlurPw();
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await login({ email, password: pw });
+ window.location.href = '/';
+ } catch (err) {
+ const status = (err as { response?: { status?: number } })?.response?.status;
+ if (status && [400, 401].includes(status)) setFailOpen(true);
+ else setGlobalErr(getMsg(err, '로그인 중 오류가 발생했습니다.'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* 로고 */}
+
+
+
+
+
+
+
+
+ {/* 로그인 폼 */}
+
+
+
+ {/* 로그인 실패 모달 */}
+ setFailOpen(false)}
+ title='이메일 또는 비밀번호가 올바르지 않습니다'
+ description={다시 한 번 확인해 주세요.
}
+ variant='warning'
+ primaryText='확인'
+ onPrimary={() => setFailOpen(false)}
+ />
+
+ );
+};
+
+// Header/Footer 제거용 전용 레이아웃
+LoginPage.getLayout = (page: ReactNode) => page;
+
+export default LoginPage;
diff --git a/src/pages/my-profile/index.tsx b/src/pages/my-profile/index.tsx
new file mode 100644
index 0000000..4c5ef50
--- /dev/null
+++ b/src/pages/my-profile/index.tsx
@@ -0,0 +1,171 @@
+import Image from 'next/image';
+import Link from 'next/link';
+import { useEffect, useMemo, useState } from 'react';
+import Frame from '@/components/layout/frame/frame';
+import Button from '@/components/ui/button/button';
+import Table from '@/components/ui/table/Table';
+import type { TableRowProps } from '@/components/ui/table/TableRowProps';
+import { ICONS, ICON_SIZES } from '@/constants/icon';
+import { useUserApplications } from '@/context/userApplicationsProvider';
+import useAuth from '@/hooks/useAuth';
+import type { ApiResponse } from '@/types/api';
+import type { ApplicationItem } from '@/types/applications';
+import type { User, UserType } from '@/types/user';
+
+export default function MyProfileDetailPage() {
+ const { isLogin, user } = useAuth();
+ const { applications, isLoading } = useUserApplications();
+
+ // 테이블 페이지네이션
+ const [offset, setOffset] = useState(0);
+ const limit = 5;
+
+ // 프로필 비었는지 판단 (User | null 안전)
+ function isProfileEmpty(u: User | null): boolean {
+ const name = u?.name?.trim() ?? '';
+ const phone = u?.phone?.trim() ?? '';
+ const address = (u?.address as string | undefined)?.trim() ?? '';
+ const bio = u?.bio?.trim() ?? '';
+ return !name && !phone && !address && !bio;
+ }
+ const profileIsEmpty = useMemo(() => isProfileEmpty(user), [user]);
+
+ const headers: string[] = ['가게명', '근무일시', '시급', '상태'];
+ const userType: UserType = 'employee';
+
+ // 서버 응답 → TableRowProps 매핑
+ const rows: TableRowProps[] = useMemo(() => {
+ return applications.map((app: ApiResponse) => {
+ const a = app.item;
+ const status =
+ a.status === 'accepted' ? 'approved' : a.status === 'rejected' ? 'rejected' : 'pending';
+ return {
+ id: a.id,
+ name: a.shop.item.name,
+ hourlyPay: `${a.notice.item.hourlyPay.toLocaleString()}원`,
+ startsAt: a.notice.item.startsAt,
+ workhour: a.notice.item.workhour,
+ status,
+ // employee 표에서는 미사용 — 타입만 충족
+ bio: '',
+ phone: '',
+ };
+ });
+ }, [applications]);
+
+ const pagedRows = useMemo(() => rows.slice(offset, offset + limit), [rows, offset]);
+
+ // rows 변화 시 첫 페이지로 리셋 (페이지네이션 UX 보강)
+ useEffect(() => {
+ setOffset(0);
+ }, [rows.length]);
+
+ return (
+
+
+
내 프로필
+
+ {/* 프로필이 없으면 등록 프레임 */}
+ {profileIsEmpty ? (
+
+ ) : (
+ // 프로필 카드(피그마 스타일)
+
+
+
+
이름
+
+ {user?.name || '—'}
+
+
+ {/* 연락처 */}
+
+
+ {user?.phone || '—'}
+
+
+ {/* 선호 지역 */}
+
+
+ 선호 지역: {(user?.address as string) || '—'}
+
+
+ {/* 소개 */}
+ {user?.bio && (
+
+ {user.bio}
+
+ )}
+
+
+ {/* 우상단 편집 버튼 */}
+
+
+
+
+
+ )}
+
+
+ {/* 신청 내역 — 프로필 있고 로그인 상태일 때만 */}
+ {!profileIsEmpty && isLogin && (
+
+ {isLoading ? (
+ 불러오는 중…
+ ) : rows.length === 0 ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/pages/my-profile/register.tsx b/src/pages/my-profile/register.tsx
new file mode 100644
index 0000000..3a68053
--- /dev/null
+++ b/src/pages/my-profile/register.tsx
@@ -0,0 +1,234 @@
+import { Icon } from '@/components/ui';
+import Button from '@/components/ui/button/button';
+import Dropdown from '@/components/ui/dropdown/dropdown';
+import Input from '@/components/ui/input/input';
+import Modal from '@/components/ui/modal/modal';
+import useAuth from '@/hooks/useAuth';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+
+import { ADDRESS_CODE, type AddressCode } from '@/constants/dropdown';
+
+/** 폼 타입 */
+type ProfileForm = {
+ name: string;
+ phone: string;
+ region: AddressCode | '';
+ bio: string;
+};
+
+export default function MyProfileRegisterPage() {
+ const router = useRouter();
+ const { isLogin, user, updateUser } = useAuth();
+
+ const [formState, setFormState] = useState({
+ name: '',
+ phone: '',
+ region: '',
+ bio: '',
+ });
+
+ const [nameErrorMessage, setNameErrorMessage] = useState(null);
+ const [phoneErrorMessage, setPhoneErrorMessage] = useState(null);
+ const [regionErrorMessage, setRegionErrorMessage] = useState(null);
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isDoneOpen, setIsDoneOpen] = useState(false); // 완료 모달
+ const [isCancelOpen, setIsCancelOpen] = useState(false); // 취소 확인 모달
+
+ // 로그인 가드
+ useEffect(() => {
+ if (!isLogin) router.replace('/login');
+ }, [isLogin, router]);
+
+ // 기존 값 프리필(컨텍스트 user 사용)
+ useEffect(() => {
+ if (!isLogin || !user) return;
+ setFormState({
+ name: user.name ?? '',
+ phone: user.phone ?? '',
+ region: (user.address as AddressCode) ?? '',
+ bio: user.bio ?? '',
+ });
+ }, [isLogin, user]);
+
+ const updateFormField = (k: K, v: ProfileForm[K]) =>
+ setFormState(prev => ({ ...prev, [k]: v }));
+
+ const isFormSubmittable =
+ !!formState.name &&
+ !!formState.phone &&
+ !!formState.region &&
+ !nameErrorMessage &&
+ !phoneErrorMessage &&
+ !regionErrorMessage &&
+ !isSubmitting;
+
+ const handleSubmit: React.FormEventHandler = async e => {
+ e.preventDefault();
+
+ if (!formState.name.trim()) setNameErrorMessage('이름을 입력해 주세요.');
+ if (!/^0\d{1,2}-\d{3,4}-\d{4}$/.test(formState.phone.trim()))
+ setPhoneErrorMessage('연락처 형식(010-1234-5678)으로 입력해 주세요.');
+ if (!formState.region) setRegionErrorMessage('선호 지역을 선택해 주세요.');
+
+ if (!isFormSubmittable || !user) return;
+
+ setIsSubmitting(true);
+ try {
+ // 서버 반영 + 컨텍스트 동기화
+ await updateUser({
+ name: formState.name.trim(),
+ phone: formState.phone.trim(),
+ address: formState.region,
+ bio: formState.bio,
+ });
+ setIsDoneOpen(true);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ {/* 우상단 닫기(X) 버튼 */}
+
+ 내 프로필
+
+
+
+ {/* 등록 완료 모달 */}
+ setIsDoneOpen(false)}
+ title='등록이 완료되었습니다.'
+ variant='success'
+ primaryText='확인'
+ onPrimary={() => {
+ setIsDoneOpen(false);
+ router.replace('/my-profile');
+ }}
+ />
+
+ {/* 취소 확인 모달 */}
+ setIsCancelOpen(false)}
+ title='등록을 취소하시겠습니까?'
+ description={작성 중인 내용은 저장되지 않습니다.
}
+ variant='warning'
+ secondaryText='아니오'
+ onSecondary={() => setIsCancelOpen(false)}
+ primaryText='예'
+ onPrimary={() => router.replace('/my-profile')}
+ />
+
+ );
+}
diff --git a/src/pages/my-shop/edit.tsx b/src/pages/my-shop/edit.tsx
new file mode 100644
index 0000000..4d5f427
--- /dev/null
+++ b/src/pages/my-shop/edit.tsx
@@ -0,0 +1,59 @@
+import { getShop, postPresignedUrl, putShop, uploadImage } from '@/api/employer';
+import ShopForm from '@/components/features/my-shop/shopForm';
+import { Header, Wrapper } from '@/components/layout';
+import useAuth from '@/hooks/useAuth';
+import { NextPageWithLayout } from '@/pages/_app';
+import { RegisterFormData } from '@/types/myShop';
+import { useEffect, useState } from 'react';
+
+const Edit: NextPageWithLayout = () => {
+ const { user } = useAuth();
+ const [editData, setEditData] = useState(null);
+
+ useEffect(() => {
+ const fetchShop = async () => {
+ if (user?.shop) {
+ const res = await getShop(user.shop.item.id);
+ setEditData(res.item);
+ }
+ };
+ fetchShop();
+ }, [user]);
+
+ const handleEdit = async (editData: RegisterFormData) => {
+ if (!user?.shop) return;
+ let imageUrl = editData.imageUrl ?? '';
+ if (editData.image) {
+ const presignedUrl = await postPresignedUrl(editData.image.name);
+ await uploadImage(presignedUrl, editData.image);
+ try {
+ const url = new URL(presignedUrl);
+ const shortUrl = url.origin + url.pathname;
+ imageUrl = shortUrl;
+ } catch (error) {
+ alert(error);
+ }
+ }
+ // 🟣 PUT 요청
+ const { originalHourlyPay, ...shopData } = editData;
+ const numericPay =
+ typeof originalHourlyPay === 'string'
+ ? Number(originalHourlyPay.replace(/,/g, ''))
+ : originalHourlyPay;
+ await putShop(user.shop.item.id, { ...shopData, originalHourlyPay: numericPay, imageUrl });
+ };
+ return (
+ <>
+
+ >
+ );
+};
+
+Edit.getLayout = page => (
+
+
+ {page}
+
+);
+
+export default Edit;
diff --git a/src/pages/my-shop/index.tsx b/src/pages/my-shop/index.tsx
new file mode 100644
index 0000000..89d9205
--- /dev/null
+++ b/src/pages/my-shop/index.tsx
@@ -0,0 +1,171 @@
+import { getNotice, getShop } from '@/api/employer';
+import IndexModal from '@/components/features/my-shop/indexModal';
+import { Container, Frame } from '@/components/layout';
+import { Button, Notice, Post } from '@/components/ui';
+import useAuth from '@/hooks/useAuth';
+import { NoticeItem, NoticeResponse, ShopItem } from '@/types/myShop';
+import Link from 'next/link';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+const Myshop = () => {
+ const { user, bootstrapped } = useAuth();
+ const [shopData, setShopData] = useState({
+ id: '',
+ name: '',
+ category: '',
+ address1: '',
+ imageUrl: '',
+ originalHourlyPay: 0,
+ description: '',
+ });
+ const [shopNotice, setShopNotice] = useState([]);
+ const [nextOffset, setNextOffset] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [guestRedirect, setGuestRedirect] = useState(false);
+ const [employeeRedirect, setEmployeeRedirect] = useState(false);
+ const observerRef = useRef(null);
+
+ useEffect(() => {
+ if (!bootstrapped) return;
+ if (user === null) {
+ setGuestRedirect(true);
+ return;
+ }
+ if (user?.type === 'employee') {
+ setEmployeeRedirect(true);
+ return;
+ }
+ }, [user, bootstrapped]);
+
+ useEffect(() => {
+ if (!user?.shop) return;
+ const shopId = user.shop.item.id;
+
+ const get = async () => {
+ try {
+ const shopRes = await getShop(shopId);
+ const { description, ...rest } = shopRes.item;
+ setShopData({ ...rest, shopDescription: description });
+ setShopNotice([]);
+ setNextOffset(0);
+ loadMoreNotice();
+ } catch (error) {
+ alert(error);
+ }
+ };
+ get();
+ }, [user]);
+
+ const loadMoreNotice = useCallback(async () => {
+ if (!user?.shop || nextOffset === null || loading) return;
+ setLoading(true);
+ try {
+ const noticeRes: NoticeResponse = await getNotice(user.shop.item.id, {
+ offset: nextOffset,
+ limit: 6,
+ });
+ setShopNotice(prevShopNotice => {
+ const newItems = noticeRes.items.map(i => i.item);
+ const merged = [...prevShopNotice, ...newItems];
+ const unique = merged.filter(
+ (item, index, self) => index === self.findIndex(i => i.id === item.id)
+ );
+ return unique;
+ });
+ setNextOffset(noticeRes.hasNext ? nextOffset + noticeRes.items.length : null);
+ } catch (error) {
+ alert(error);
+ } finally {
+ setLoading(false);
+ }
+ }, [user?.shop, nextOffset, loading]);
+
+ useEffect(() => {
+ if (!observerRef.current) return;
+ const observer = new IntersectionObserver(
+ entries => {
+ if (entries[0].isIntersecting) {
+ loadMoreNotice();
+ }
+ },
+ { threshold: 0.5 }
+ );
+ observer.observe(observerRef.current);
+ return () => observer.disconnect();
+ }, [loadMoreNotice]);
+
+ return (
+ <>
+
+ {!user?.shop ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+ {shopNotice.length > 0 ? (
+
+
+
내가 등록한 공고
+
+ {shopNotice.map(item => {
+ const mergedNotice = {
+ ...item,
+ imageUrl: shopData.imageUrl,
+ name: shopData.name,
+ address1: shopData.address1,
+ shopId: shopData.id,
+ originalHourlyPay: shopData.originalHourlyPay,
+ };
+ return
;
+ })}
+
+
+ {loading &&
불러오는 중...
}
+
+
+
+ ) : (
+
+ )}
+
+ >
+ )}
+ >
+ );
+};
+
+export default Myshop;
diff --git a/src/pages/my-shop/register.tsx b/src/pages/my-shop/register.tsx
new file mode 100644
index 0000000..14c07fb
--- /dev/null
+++ b/src/pages/my-shop/register.tsx
@@ -0,0 +1,42 @@
+import { postPresignedUrl, postShop, uploadImage } from '@/api/employer';
+import ShopForm from '@/components/features/my-shop/shopForm';
+import { Header, Wrapper } from '@/components/layout';
+import { NextPageWithLayout } from '@/pages/_app';
+import { RegisterFormData } from '@/types/myShop';
+
+const Register: NextPageWithLayout = () => {
+ const handleRegister = async (formData: RegisterFormData) => {
+ let imageUrl = formData.imageUrl ?? '';
+ if (!imageUrl && formData.image instanceof File) {
+ const presignedUrl = await postPresignedUrl(formData.image.name);
+ await uploadImage(presignedUrl, formData.image);
+ try {
+ const url = new URL(presignedUrl);
+ const shortUrl = url.origin + url.pathname;
+ imageUrl = shortUrl;
+ } catch (error) {
+ alert(error);
+ }
+ }
+ const { originalHourlyPay, ...shopData } = formData;
+ const numericPay =
+ typeof originalHourlyPay === 'string'
+ ? Number(originalHourlyPay.replace(/,/g, ''))
+ : originalHourlyPay;
+ await postShop({ ...shopData, originalHourlyPay: numericPay, imageUrl });
+ };
+ return (
+ <>
+
+ >
+ );
+};
+
+Register.getLayout = page => (
+
+
+ {page}
+
+);
+
+export default Register;
diff --git a/src/pages/notices/[shopId]/[noticeId]/index.tsx b/src/pages/notices/[shopId]/[noticeId]/index.tsx
new file mode 100644
index 0000000..ef1894e
--- /dev/null
+++ b/src/pages/notices/[shopId]/[noticeId]/index.tsx
@@ -0,0 +1,209 @@
+import { RecentNoticeList } from '@/components/features';
+import { useRecentNotice } from '@/components/features/noticeList/hooks/useRecentNotice';
+import { Button, Modal, Notice } from '@/components/ui';
+import { useToast } from '@/context/toastContext';
+import { useUserApplications } from '@/context/userApplicationsProvider';
+import useAuth from '@/hooks/useAuth';
+import axiosInstance from '@/lib/axios';
+import { getNoticeStatus } from '@/lib/utils/getNoticeStatus';
+import { toNoticeCard } from '@/lib/utils/parse';
+import type { NoticeCard } from '@/types/notice';
+import { UserProfile, UserRole } from '@/types/user';
+import type { GetServerSideProps } from 'next';
+import { useRouter } from 'next/router';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+interface ModalItems {
+ title?: string;
+ primaryText?: string;
+ secondaryText?: string;
+ onPrimary?: () => void;
+ onSecondary?: () => void;
+}
+interface ApplyItems extends ModalItems {
+ noProfile?: ModalItems;
+ profile?: ModalItems;
+ cancel?: ModalItems;
+}
+
+// 권한별 모달 템플릿
+const APPLY_ITEMS: Record = {
+ guest: {
+ title: '로그인이 필요합니다',
+ primaryText: '로그인하기',
+ secondaryText: '닫기',
+ },
+ employee: {
+ profile: {
+ title: '아르바이트 신청을 하시겠습니까?',
+ primaryText: '신청하기',
+ secondaryText: '아니오',
+ },
+ noProfile: {
+ title: '내 프로필을 먼저 등록해주세요',
+ primaryText: '프로필 등록',
+ secondaryText: '닫기',
+ },
+ cancel: {
+ title: '아르바이트 신청을 취소하시겠습니까?',
+ primaryText: '취소하기',
+ secondaryText: '아니오',
+ },
+ },
+ employer: {
+ title: '사장님은 신청할 수 없습니다',
+ primaryText: '확인',
+ },
+};
+
+// 프로필 정보 존재 여부 확인
+function hasProfileFields(user: UserProfile | null) {
+ if (!user) return false;
+ return Boolean(user.name && user.phone && user.address && user.bio);
+}
+
+// 공고 상세 초기 렌더링 SSR
+export const getServerSideProps: GetServerSideProps<{ notice: NoticeCard }> = async ({
+ params,
+}) => {
+ const { shopId, noticeId } = params as { shopId: string; noticeId: string };
+ try {
+ const noticeRes = await axiosInstance.get(`/shops/${shopId}/notices/${noticeId}`);
+ return { props: { notice: toNoticeCard(noticeRes.data) } }; // API 응답 NoticeCard로 평탄화
+ } catch {
+ return {
+ notFound: true,
+ };
+ }
+};
+
+const NoticeDetail = ({ notice }: { notice: NoticeCard }) => {
+ const { role, isLogin, user } = useAuth();
+ const { isApplied, applyNotice, cancelNotice, error } = useUserApplications();
+ const { showToast } = useToast();
+ const { handleRecentNotice } = useRecentNotice(notice);
+ const router = useRouter();
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modal, setModal] = useState(null);
+
+ const status = getNoticeStatus(notice.closed, notice.startsAt);
+ const canApply = useMemo(() => status === 'open', [status]);
+
+ // 공고 지원하기
+ const handleApplyClick = useCallback(async () => {
+ if (!canApply) return; // 지난공고 , 공고마감 무시
+
+ // 로그인 여부 확인 -> 미로그인시 : 로그인 / 닫기 모달
+ if (!isLogin) {
+ const items = APPLY_ITEMS.guest;
+ setModal({
+ ...items,
+ onPrimary: () => router.push('/login'),
+ onSecondary: () => setModalOpen(false),
+ });
+ setModalOpen(true);
+ return;
+ }
+
+ // 사장님 계정은 신청 불가 -> 닫기 모달
+ if (role === 'employer') {
+ const items = APPLY_ITEMS.employer;
+ setModal({
+ ...items,
+ onPrimary: () => setModalOpen(false),
+ });
+ setModalOpen(true);
+ return;
+ }
+
+ // 알바생 프로필 여부 확인 -> 프로필 미작성시 : 마이프로필 / 닫기 모달
+ const hasProfile = hasProfileFields(user);
+ if (!hasProfile) {
+ const items = APPLY_ITEMS.employee.noProfile;
+ setModal({
+ ...items,
+ onPrimary: () => router.push('/my-profile'),
+ onSecondary: () => setModalOpen(false),
+ });
+ setModalOpen(true);
+ return;
+ }
+
+ // 기존 신청 여부 확인
+ // 이미 신청된 상태 -> 취소 여부 모달
+ if (isApplied(notice.id)) {
+ const items = APPLY_ITEMS.employee.cancel;
+ setModal({
+ ...items,
+ // 아르바이트 지원 취소
+ onPrimary: async () => {
+ try {
+ await cancelNotice(notice.id);
+ showToast('신청이 취소되었습니다.');
+ } catch {
+ showToast(error ?? '신청 취소 중 오류가 발생했습니다.');
+ } finally {
+ setModalOpen(false);
+ }
+ },
+ onSecondary: () => setModalOpen(false),
+ });
+ setModalOpen(true);
+ return;
+ }
+
+ // 아직 신청하지 않은 상태 -> 신청 여부 모달
+ const items = APPLY_ITEMS.employee.profile;
+ setModal({
+ ...items,
+ onPrimary: async () => {
+ try {
+ await applyNotice(notice.shopId, notice.id);
+ showToast('신청이 완료되었습니다.');
+ } catch {
+ showToast(error ?? '신청 중 오류가 발생했습니다.');
+ } finally {
+ setModalOpen(false);
+ }
+ },
+ onSecondary: () => setModalOpen(false),
+ });
+ setModalOpen(true);
+
+ // isApplied는 내부에서 applications에만 의존하므로 배열에 제외
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [canApply, isLogin, role, user, notice, router, applyNotice, cancelNotice, showToast, error]);
+
+ // 최근 본 공고
+ useEffect(() => {
+ handleRecentNotice();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return (
+
+
+
+ setModalOpen(false)}
+ variant='warning'
+ title={modal?.title ?? '유저 정보를 확인해주세요'}
+ primaryText={modal?.primaryText ?? '확인'}
+ onPrimary={modal?.onPrimary ?? (() => setModalOpen(false))}
+ secondaryText={modal?.secondaryText}
+ onSecondary={modal?.onSecondary}
+ />
+
+
+
+ );
+};
+export default NoticeDetail;
diff --git a/src/pages/search.tsx b/src/pages/search.tsx
new file mode 100644
index 0000000..418e8a9
--- /dev/null
+++ b/src/pages/search.tsx
@@ -0,0 +1,12 @@
+import { NoticeListSection } from '@/components/features';
+import { useRouter } from 'next/router';
+import { useMemo } from 'react';
+
+const Search = () => {
+ const router = useRouter();
+ const q = typeof router.query.q === 'string' ? router.query.q : '';
+ const initialFilters = useMemo(() => (q ? { keyword: q } : undefined), [q]);
+
+ return ;
+};
+export default Search;
diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx
new file mode 100644
index 0000000..7eae3c8
--- /dev/null
+++ b/src/pages/signup.tsx
@@ -0,0 +1,308 @@
+import logo from '@/assets/images/logo.svg';
+import { Icon } from '@/components/ui';
+import Button from '@/components/ui/button/button';
+import Input from '@/components/ui/input/input';
+import Modal from '@/components/ui/modal/modal';
+import useAuth from '@/hooks/useAuth';
+import { cn } from '@/lib/utils/cn';
+import Image from 'next/image';
+import Link from 'next/link';
+import { useState, type ReactNode } from 'react';
+import type { NextPageWithLayout } from './_app';
+
+type MemberType = 'employee' | 'employer';
+
+const getMsg = (err: unknown, fallback: string) => {
+ if (typeof err === 'string') return err;
+ if (err && typeof err === 'object') {
+ const e = err as {
+ response?: { status?: number; data?: { message?: string } };
+ message?: string;
+ };
+ return e.response?.data?.message ?? e.message ?? fallback;
+ }
+ return fallback;
+};
+
+const SignupPage: NextPageWithLayout = () => {
+ const { signup } = useAuth();
+
+ const [email, setEmail] = useState('');
+ const [pw, setPw] = useState('');
+ const [pw2, setPw2] = useState('');
+ const [type, setType] = useState('employee');
+
+ const [emailErr, setEmailErr] = useState(null);
+ const [pwErr, setPwErr] = useState(null);
+ const [pw2Err, setPw2Err] = useState(null);
+
+ const [loading, setLoading] = useState(false);
+ const [dupOpen, setDupOpen] = useState(false);
+ const [successOpen, setSuccessOpen] = useState(false);
+ const [globalErr, setGlobalErr] = useState(null);
+
+ const onBlurEmail = (e: React.FocusEvent) => {
+ if (e.currentTarget.validity.typeMismatch) setEmailErr('이메일 형식으로 작성해 주세요.');
+ else setEmailErr(null);
+ };
+ const onBlurPw = () => setPwErr(pw.length < 8 ? '8자 이상 입력해주세요.' : null);
+ const onBlurPw2 = () => setPw2Err(pw !== pw2 ? '비밀번호가 일치하지 않습니다.' : null);
+
+ const canSubmit =
+ !!email && pw.length >= 8 && !!pw2 && pw === pw2 && !emailErr && !pwErr && !pw2Err && !loading;
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setGlobalErr(null);
+
+ if (!e.currentTarget.checkValidity()) {
+ e.currentTarget.reportValidity();
+ return;
+ }
+ if (!canSubmit) {
+ onBlurEmail({
+ currentTarget: e.currentTarget.email,
+ } as unknown as React.FocusEvent);
+ onBlurPw();
+ onBlurPw2();
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await signup({ email, password: pw, type });
+ setSuccessOpen(true); // ✅ alert → 모달
+ } catch (err) {
+ const status = (err as { response?: { status?: number } })?.response?.status;
+ if (status === 409) setDupOpen(true);
+ else setGlobalErr(getMsg(err, '회원가입 중 오류가 발생했습니다.'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ function TypePill({
+ value,
+ label,
+ className,
+ }: {
+ value: MemberType;
+ label: string;
+ className?: string;
+ }) {
+ const checked = type === value;
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {/* 409 중복 이메일 모달 */}
+ setDupOpen(false)}
+ title='이미 사용중인 이메일입니다'
+ description={다른 이메일로 가입을 진행해주세요.
}
+ variant='warning'
+ primaryText='확인'
+ onPrimary={() => setDupOpen(false)}
+ />
+
+ {/* 가입 성공 모달 */}
+ setSuccessOpen(false)}
+ title='가입이 완료되었습니다'
+ description={이메일과 비밀번호로 로그인해 주세요.
}
+ variant='success'
+ primaryText='로그인하기'
+ onPrimary={() => {
+ setSuccessOpen(false);
+ window.location.href = '/login';
+ }}
+ />
+
+ );
+};
+
+// Header/Footer 제거용 전용 레이아웃
+SignupPage.getLayout = (page: ReactNode) => page;
+
+export default SignupPage;
diff --git a/src/stories/DesignTokens/ColorPalette.stories.tsx b/src/stories/DesignTokens/ColorPalette.stories.tsx
index 946e448..28aa1cc 100644
--- a/src/stories/DesignTokens/ColorPalette.stories.tsx
+++ b/src/stories/DesignTokens/ColorPalette.stories.tsx
@@ -1,4 +1,4 @@
-import type { Meta, StoryObj } from '@storybook/react';
+import type { Meta, StoryObj } from '@storybook/nextjs';
const colors = [
'gray-50',
@@ -7,10 +7,19 @@ const colors = [
'gray-300',
'gray-400',
'gray-500',
+ 'gray-600',
+ 'gray-700',
+ 'gray-800',
+ 'gray-900',
'red-100',
'red-200',
'red-300',
'red-400',
+ 'red-500',
+ 'red-600',
+ 'red-700',
+ 'red-800',
+ 'red-900',
'blue-100',
'blue-200',
'green-100',
@@ -30,7 +39,7 @@ type Story = StoryObj;
export const Palette: Story = {
render: () => (
-
+
{colors.map(color => (
(
{texts.map(t => (
-
- text-{t.name} : {t.size}px - 가나다 ABC abc 123
+
+ {t.name}
+ 피그마 폰트명: {t.label}
+ pc:{t.size}px
+ mob:{t.size}px
))}
diff --git a/src/stories/example/Button.stories.ts b/src/stories/example/Button.stories.ts
deleted file mode 100644
index 2897256..0000000
--- a/src/stories/example/Button.stories.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite';
-import { fn } from 'storybook/test';
-import { Button } from './Button';
-
-// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
-const meta = {
- title: 'Example/Button',
- component: Button,
- parameters: {
- // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
- layout: 'centered',
- },
- // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
- tags: ['autodocs'],
- // More on argTypes: https://storybook.js.org/docs/api/argtypes
- argTypes: {
- backgroundColor: { control: 'color' },
- },
- // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
- args: { onClick: fn() },
-} satisfies Meta
;
-
-export default meta;
-type Story = StoryObj;
-
-// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
-export const Primary: Story = {
- args: {
- primary: true,
- label: 'Button',
- },
-};
-
-export const Secondary: Story = {
- args: {
- label: 'Button',
- },
-};
-
-export const Large: Story = {
- args: {
- size: 'large',
- label: 'Button',
- },
-};
-
-export const Small: Story = {
- args: {
- size: 'small',
- label: 'Button',
- },
-};
diff --git a/src/stories/example/Button.tsx b/src/stories/example/Button.tsx
deleted file mode 100644
index 87ac759..0000000
--- a/src/stories/example/Button.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import './button.css';
-
-export interface ButtonProps {
- /** Is this the principal call to action on the page? */
- primary?: boolean;
- /** What background color to use */
- backgroundColor?: string;
- /** How large should the button be? */
- size?: 'small' | 'medium' | 'large';
- /** Button contents */
- label: string;
- /** Optional click handler */
- onClick?: () => void;
-}
-
-/** Primary UI component for user interaction */
-export const Button = ({
- primary = false,
- size = 'medium',
- backgroundColor,
- label,
- ...props
-}: ButtonProps) => {
- const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
- return (
-
- );
-};
diff --git a/src/stories/example/Header.stories.ts b/src/stories/example/Header.stories.ts
deleted file mode 100644
index 29fff1e..0000000
--- a/src/stories/example/Header.stories.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite';
-
-import { fn } from 'storybook/test';
-
-import { Header } from './Header';
-
-const meta = {
- title: 'Example/Header',
- component: Header,
- // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
- tags: ['autodocs'],
- parameters: {
- // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
- layout: 'fullscreen',
- },
- args: {
- onLogin: fn(),
- onLogout: fn(),
- onCreateAccount: fn(),
- },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const LoggedIn: Story = {
- args: {
- user: {
- name: 'Jane Doe',
- },
- },
-};
-
-export const LoggedOut: Story = {};
diff --git a/src/stories/example/Header.tsx b/src/stories/example/Header.tsx
deleted file mode 100644
index 31dec79..0000000
--- a/src/stories/example/Header.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Button } from './Button';
-import './header.css';
-
-type User = {
- name: string;
-};
-
-export interface HeaderProps {
- user?: User;
- onLogin?: () => void;
- onLogout?: () => void;
- onCreateAccount?: () => void;
-}
-
-export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
-
-);
diff --git a/src/stories/example/button.css b/src/stories/example/button.css
deleted file mode 100644
index 4e3620b..0000000
--- a/src/stories/example/button.css
+++ /dev/null
@@ -1,30 +0,0 @@
-.storybook-button {
- display: inline-block;
- cursor: pointer;
- border: 0;
- border-radius: 3em;
- font-weight: 700;
- line-height: 1;
- font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-}
-.storybook-button--primary {
- background-color: #555ab9;
- color: white;
-}
-.storybook-button--secondary {
- box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
- background-color: transparent;
- color: #333;
-}
-.storybook-button--small {
- padding: 10px 16px;
- font-size: 12px;
-}
-.storybook-button--medium {
- padding: 11px 20px;
- font-size: 14px;
-}
-.storybook-button--large {
- padding: 12px 24px;
- font-size: 16px;
-}
diff --git a/src/stories/example/header.css b/src/stories/example/header.css
deleted file mode 100644
index 5efd46c..0000000
--- a/src/stories/example/header.css
+++ /dev/null
@@ -1,32 +0,0 @@
-.storybook-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- border-bottom: 1px solid rgba(0, 0, 0, 0.1);
- padding: 15px 20px;
- font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-}
-
-.storybook-header svg {
- display: inline-block;
- vertical-align: top;
-}
-
-.storybook-header h1 {
- display: inline-block;
- vertical-align: top;
- margin: 6px 0 6px 10px;
- font-weight: 700;
- font-size: 20px;
- line-height: 1;
-}
-
-.storybook-header button + button {
- margin-left: 10px;
-}
-
-.storybook-header .welcome {
- margin-right: 10px;
- color: #333;
- font-size: 14px;
-}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 2b9a9f4..438819f 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -10,11 +10,20 @@
--gray-300: #cbc9cf;
--gray-400: #a4a1aa;
--gray-500: #7d7986;
+ --gray-600: #615e68;
+ --gray-700: #4a474f;
+ --gray-800: #333137;
+ --gray-900: #1d1c1f;
--red-100: #ffebe7;
--red-200: #ffaf9b;
--red-300: #ff8d72;
--red-400: #ff4040;
+ --red-500: #ea3c12;
+ --red-600: #d63810;
+ --red-700: #b32e0d;
+ --red-800: #8f250a;
+ --red-900: #6a1c08;
--blue-100: #cce6ff;
--blue-200: #0080ff;
@@ -26,6 +35,9 @@
--background: #f8f9fa;
+ --modal-frame: #f9f9f9;
+ --modal-dimmed: rgba(0, 0, 0, 0.7);
+
/* font size */
/* Caption */
@@ -39,7 +51,7 @@
--ls-body-s: 0em;
--fs-body-m: 1rem; /* 16px */
- --lh-body-m: 1.5; /* 24/16 = 150% */
+ --lh-body-m: 1.25; /* 20/16 = 150% */
--ls-body-m: 0em;
--fs-body-l: 1rem; /* 16px */
@@ -61,9 +73,6 @@
--fs-heading-l: 1.75rem; /* 28px */
--lh-heading-l: 1.286; /* 36/28 ≈ 129% */
--ls-heading-l: 0.02em;
-
- --modal-frame: #f9f9f9;
- --modal-dimmed: rgba(0, 0, 0, 0.7);
}
/* 다크모드 */
.dark {
@@ -74,11 +83,20 @@
--gray-300: #4c515c;
--gray-400: #6c717c;
--gray-500: #8c919c;
-
- --red-100: #2e0b0b;
- --red-200: #802626;
- --red-300: #cc3b3b;
- --red-400: #ff5c5c;
+ --gray-600: #a0a4ae;
+ --gray-700: #babec6;
+ --gray-800: #d4d7dc;
+ --gray-900: #eeeff2;
+
+ --red-100: #2a0f08;
+ --red-200: #47180c;
+ --red-300: #752312;
+ --red-400: #a8321b;
+ --red-500: #c83116;
+ --red-600: #e04d36;
+ --red-700: #f2725b;
+ --red-800: #f6a491;
+ --red-900: #fbe1db;
--blue-100: #102a40;
--blue-200: #4da6ff;
@@ -88,7 +106,7 @@
/* Base colors */
--black: #f5f5f5;
- --white: #111322;
+ --white: #12131a;
--background: #121212;
}
@@ -137,3 +155,41 @@ body {
-webkit-mask-position: center;
-webkit-mask-size: contain;
}
+
+.base-input {
+ @apply rounded-md border border-gray-300 bg-white px-5 py-4 text-body-l placeholder:text-gray-400;
+}
+
+.scroll-bar {
+ overflow: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--gray-300) transparent;
+ scroll-behavior: smooth;
+ &::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ background: transparent;
+ }
+ &::-webkit-scrollbar-track {
+ margin: 4px 0;
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ border-radius: 40px;
+ background-color: var(--gray-300);
+ }
+ &::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+}
+.none-scroll-bar {
+ overflow: auto;
+ -ms-overflow-style: none; /* 인터넷 익스플로러 */
+ scrollbar-width: none; /* 파이어폭스 */
+ &:-webkit-scrollbar {
+ display: none;
+ }
+}
+.icon-btn {
+ line-height: 0;
+}
diff --git a/src/types/api.ts b/src/types/api.ts
new file mode 100644
index 0000000..69a50a1
--- /dev/null
+++ b/src/types/api.ts
@@ -0,0 +1,58 @@
+import { User } from './user';
+
+/* -------------------- 공통 타입 -------------------- */
+export interface Link {
+ rel: string;
+ description: string;
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
+ href: string;
+}
+
+export interface PaginatedResponse {
+ offset: number;
+ limit: number;
+ count: number;
+ hasNext: boolean;
+}
+export interface ApiResponse {
+ item: T;
+ links: Link[];
+}
+export interface ApiItemsResponse {
+ items: T[];
+ links: Link[];
+}
+
+export interface ApiError {
+ message: string;
+}
+
+export interface AuthRequest {
+ email: string;
+ password: string;
+}
+
+export interface AuthResponse {
+ token: string;
+ user: { item: User; href: string };
+}
+
+export type sort = 'time' | 'pay' | 'hour' | 'shop' | undefined;
+export interface FilterQuery {
+ address?: string[];
+ startsAtGte?: string;
+ hourlyPayGte?: number;
+}
+
+export interface NoticeQuery extends FilterQuery {
+ offset?: number;
+ limit?: number;
+ keyword?: string;
+ sort?: sort;
+}
+
+export interface ApiAsync {
+ isLoading: boolean;
+ isInitialized: boolean;
+ error: string | null;
+}
diff --git a/src/types/applications.ts b/src/types/applications.ts
new file mode 100644
index 0000000..a8b9d12
--- /dev/null
+++ b/src/types/applications.ts
@@ -0,0 +1,33 @@
+import { ApiItemsResponse, ApiResponse, PaginatedResponse } from './api';
+import type { Notice, NoticeCard } from './notice';
+import type { Shop } from './shop';
+import { UserProfile } from './user';
+
+export type ApplicationStatus = 'pending' | 'accepted' | 'rejected' | 'canceled';
+export interface ApplicationBase {
+ item: {
+ id: string;
+ status: ApplicationStatus;
+ createdAt: string;
+ };
+}
+
+export interface ApplicationItem {
+ id: string;
+ status: ApplicationStatus;
+ createdAt: string; // ISO 문자열
+ shop: { item: Shop; href: string };
+ notice: { item: Notice; href: string };
+ user?: { id: string; name?: string; email?: string; bio?: string; phone?: string };
+}
+
+export type ApplicationListResponse = PaginatedResponse &
+ ApiItemsResponse>;
+
+export type ApplicationTableDataItem = {
+ id: string;
+ status: string;
+ shop: { item: Shop; href: string };
+ notice: { item: NoticeCard; href: string };
+ user: { item: UserProfile; href: string };
+};
diff --git a/src/types/calendar.ts b/src/types/calendar.ts
new file mode 100644
index 0000000..e009fc0
--- /dev/null
+++ b/src/types/calendar.ts
@@ -0,0 +1,51 @@
+// Calendar 관련
+export type DateInputProps = {
+ id?: string;
+ label?: string;
+ className?: string;
+ value?: Date | null;
+ onChange?: (date: Date | string) => void;
+ requiredMark?: boolean;
+ error?: string;
+};
+
+export type SelectMode = 'day' | 'month' | 'year';
+
+export interface CalendarProps {
+ value?: Date;
+ onSelect?: (date: Date) => void;
+}
+
+export interface BaseCalendarProps {
+ currentMonth: Date;
+}
+
+export interface CalendarHeaderProps extends BaseCalendarProps {
+ selectMode: SelectMode;
+ onToggleMode: () => void;
+ onChange: (offset: number) => void;
+}
+
+export interface CalendarViewProps {
+ onSelect: (value: T) => void;
+}
+
+export type DayViewProps = CalendarViewProps & { currentMonth: Date; currentDay: Date };
+export type MonthViewProps = CalendarViewProps;
+export type YearViewProps = CalendarViewProps & { currentMonth: Date };
+
+export type CalendarDay = {
+ date: Date;
+ isCurrentMonth: boolean;
+};
+
+// Time Selector 관련
+export type Period = '오전' | '오후';
+
+export interface TimeSelectorProps {
+ value?: string;
+ period: Period;
+ hours: string;
+ minutes: string;
+ onSelect?: (value: string) => void;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
deleted file mode 100644
index e69de29..0000000
diff --git a/src/types/myShop.ts b/src/types/myShop.ts
new file mode 100644
index 0000000..849ce8d
--- /dev/null
+++ b/src/types/myShop.ts
@@ -0,0 +1,40 @@
+export interface RegisterFormData {
+ name: string;
+ category?: string;
+ address1?: string;
+ address2: string;
+ originalHourlyPay: number | string;
+ description?: string;
+ image: File | null;
+ imageUrl?: string;
+}
+
+export interface ShopItem {
+ id: string;
+ name: string;
+ category: string;
+ address1: string;
+ imageUrl: string;
+ originalHourlyPay: number;
+ description: string;
+}
+
+export interface ShopResponse {
+ item: ShopItem;
+}
+
+export interface NoticeItem {
+ id: string;
+ hourlyPay: number;
+ startsAt: string;
+ workhour: number;
+ closed: boolean;
+}
+
+export interface NoticeResponse {
+ offset: number;
+ limit: number;
+ count: number;
+ hasNext: boolean;
+ items: { item: NoticeItem }[];
+}
diff --git a/src/types/notice.ts b/src/types/notice.ts
new file mode 100644
index 0000000..b0aba1d
--- /dev/null
+++ b/src/types/notice.ts
@@ -0,0 +1,45 @@
+/* -------------------- 공고 -------------------- */
+
+import { Link } from './api';
+import { Shop, ShopSummary } from './shop';
+
+// 공고 등록
+export interface NoticeBase {
+ hourlyPay: number;
+ startsAt: string; // RFC 3339
+ workhour: number;
+ description: string;
+}
+
+// 공고 목록 및 알림 목록
+export interface Notice extends NoticeBase {
+ id: string;
+ closed: boolean;
+}
+
+export type CardVariant = 'post' | 'notice';
+
+export type PostCard = Omit & ShopSummary & { shopId: string };
+
+export type NoticeCard = Notice &
+ ShopSummary & {
+ shopId: string;
+ category: string;
+ shopDescription: string;
+ };
+
+export interface NoticeItemResponse {
+ item: Notice & {
+ shop: {
+ item: Shop;
+ href: string;
+ };
+ };
+ links: Link[];
+}
+
+export type NoticeVariant = 'notice' | 'shop';
+
+export type RecentNotice = PostCard & {
+ viewedAt: string; // 저장된 시각
+};
diff --git a/src/types/shop.ts b/src/types/shop.ts
new file mode 100644
index 0000000..65c50a2
--- /dev/null
+++ b/src/types/shop.ts
@@ -0,0 +1,32 @@
+import { User } from './user';
+
+/* -------------------- 가게 -------------------- */
+// 가게등록
+export interface ShopBase {
+ id: string;
+ name: string;
+ category: string;
+ address1: string;
+ address2: string;
+ description: string;
+ imageUrl: string;
+ originalHourlyPay: number;
+}
+// 가게정보 조회
+export interface Shop extends ShopBase {
+ user?: {
+ item: User;
+ href: string;
+ };
+}
+
+export type ShopSummary = Pick;
+
+export interface NoticeShopCard {
+ shopId: string;
+ name: string;
+ category: string;
+ address1: string;
+ imageUrl: string;
+ shopDescription: string;
+}
diff --git a/src/types/user.ts b/src/types/user.ts
new file mode 100644
index 0000000..f50c547
--- /dev/null
+++ b/src/types/user.ts
@@ -0,0 +1,35 @@
+import { ApiResponse, AuthRequest, AuthResponse } from './api';
+import { Shop } from './shop';
+
+/* -------------------- 로그인 -------------------- */
+
+export type LoginRequest = AuthRequest;
+
+export type LoginResponse = ApiResponse;
+
+/* ------------------- 회원가입 ------------------ */
+
+export type UserRequest = AuthRequest & {
+ type: UserType;
+};
+
+export type UserResponse = ApiResponse;
+
+/* -------------------- 유저 -------------------- */
+export type UserType = 'employer' | 'employee';
+export type UserRole = UserType | 'guest';
+export interface UserBase {
+ id: string;
+ email: string;
+ type: UserType;
+}
+export interface UserProfile {
+ name?: string;
+ phone?: string;
+ address?: string;
+ bio?: string;
+}
+export type User = UserBase &
+ UserProfile & {
+ shop?: { item: Shop } | null;
+ };
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 1682fb8..9c966e0 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -19,12 +19,21 @@ const config: Config = {
300: 'var(--gray-300)',
400: 'var(--gray-400)',
500: 'var(--gray-500)',
+ 600: 'var(--gray-600)',
+ 700: 'var(--gray-700)',
+ 800: 'var(--gray-800)',
+ 900: 'var(--gray-900)',
},
red: {
100: 'var(--red-100)',
200: 'var(--red-200)',
300: 'var(--red-300)',
400: 'var(--red-400)',
+ 500: 'var(--red-500)',
+ 600: 'var(--red-600)',
+ 700: 'var(--red-700)',
+ 800: 'var(--red-800)',
+ 900: 'var(--red-900)',
},
blue: {
100: 'var(--blue-100)',
@@ -36,6 +45,7 @@ const config: Config = {
},
white: 'var(--white)',
black: 'var(--black)',
+ background: 'var(--background)',
'modal-frame': 'var(--modal-frame)',
'modal-dimmed': 'var(--modal-dimmed)',
@@ -76,6 +86,15 @@ const config: Config = {
tablet: '744px',
desktop: '1028px',
},
+ boxShadow: {
+ 'inset-top': '0 -4px 25px 0 rgba(0,0,0,0.1)',
+ },
+ keyframes: {
+ 'skeleton-shimmer': {
+ '0%': { backgroundPosition: '-400% 0' },
+ '100%': { backgroundPosition: '400% 0' },
+ },
+ },
},
},
plugins: [],
diff --git a/vitest.config.ts b/vitest.config.ts
deleted file mode 100644
index 3735f9b..0000000
--- a/vitest.config.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-import { defineConfig } from 'vitest/config.js';
-
-import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
-
-const dirname =
- typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
-
-// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
-export default defineConfig({
- build: {
- assetsInlineLimit: 0,
- },
-
- test: {
- projects: [
- {
- extends: true,
- plugins: [
- // The plugin will run tests for the stories defined in your Storybook config
- // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
- storybookTest({ configDir: path.join(dirname, '.storybook') }),
- ],
- test: {
- name: 'storybook',
- browser: {
- enabled: true,
- headless: true,
- provider: 'playwright',
- instances: [{ browser: 'chromium' }],
- },
- setupFiles: ['.storybook/vitest.setup.ts'],
- },
- },
- ],
- },
-});
diff --git a/vitest.shims.d.ts b/vitest.shims.d.ts
deleted file mode 100644
index a1d31e5..0000000
--- a/vitest.shims.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///