Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e19e3af
♻️ refactor: post 매번 계산하는 로직이 아니므로 memo 제거 및 href 를 id 값을 받아서 사용하도록 변…
sohyun0 Oct 8, 2025
dde62bd
♻️ refactor: href 를 id 값을 받아서 사용하도록 변경 #38
sohyun0 Oct 8, 2025
99b3425
Merge branch 'develop' into feat/#38-post
sohyun0 Oct 9, 2025
d5b31c2
♻️ refactor: 중복된 navVariant 삭제
sohyun0 Oct 9, 2025
1858d5d
Merge remote-tracking branch 'upstream/develop' into feat/#38-post
sohyun0 Oct 9, 2025
2cd453b
🔧 chore: 파일위치 변경 및 예제 파일 삭제
sohyun0 Oct 10, 2025
cc60a7e
💄 design: post card 컴포넌트 스타일 작성
sohyun0 Oct 10, 2025
fba8a8f
✨ feat: notice card 컴포넌트에 필요한 타입 작성
sohyun0 Oct 10, 2025
c625bd5
✨ feat: notice card 컴포넌트 작성 및 공고 상태 유틸함수로 분리
sohyun0 Oct 10, 2025
ae01834
✨ feat: notice card mockData 및 스토리북 작성
sohyun0 Oct 10, 2025
999a4b9
✨ feat: card 컴포넌트로 분리한 스타일링 적용
sohyun0 Oct 10, 2025
7ae5a79
✨ feat: Card 컴포넌트 테스트용 뷰 작성
sohyun0 Oct 10, 2025
da5174f
Merge branch 'develop' into feat/#38-post
sohyun0 Oct 10, 2025
64fb3c0
🔧 chore: 유틸 파일 명 변경 #38
sohyun0 Oct 10, 2025
16537cf
✨ feat: button 컴포넌트에 cn 함수 추가
sohyun0 Oct 10, 2025
bc901bf
🔧 chore: 폴더 위치 변경
sohyun0 Oct 10, 2025
415d119
🐛 fix: cssConflict 오류 해결
sohyun0 Oct 10, 2025
62f85ec
✨ feat: dropdown 스토리북 수정
sohyun0 Oct 10, 2025
580925b
♻️ refactor: notice 타입별 컴포넌트 파일로 분리 #38
sohyun0 Oct 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/components/layout/header/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,12 @@ const NAV_ITEMS: Record<UserRole, NavItems[]> = {
employer: [{ href: '/my-shop', label: '내 가게' }],
};

const getNavVariant = (isLogin: boolean, role: UserRole): UserRole =>
!isLogin ? 'guest' : role === 'employer' ? 'employer' : 'employee';

const Nav = () => {
const { role, isLogin, logout } = useAuth();
const navVariant = getNavVariant(isLogin, role);

return (
<nav className={cn('flex shrink-0 items-center gap-4 text-body-m font-bold', 'desktop:gap-10')}>
{NAV_ITEMS[navVariant].map(({ href, label }) => (
{NAV_ITEMS[role].map(({ href, label }) => (
<Link key={href} href={href}>
{label}
</Link>
Expand Down
3 changes: 2 additions & 1 deletion src/components/ui/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cn } from '@/lib/utils/cn';
import { ButtonHTMLAttributes, ElementType } from 'react';

type ButtonProps = {
Expand Down Expand Up @@ -45,7 +46,7 @@ export default function Button({
const variantClass = VARIANT_CLASS[variant];
return (
<Component
className={`${BASE_CLASS} ${variantClass} ${sizeClass} ${full ? 'w-full' : ''} ${className ?? ''}`}
className={cn(BASE_CLASS, variantClass, sizeClass, full && 'w-full', className)}
{...(Component === 'button'
? {
disabled: variant === 'disabled' || disabled,
Expand Down
61 changes: 61 additions & 0 deletions src/components/ui/card/card.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { cva, type VariantProps } from 'class-variance-authority';

export const cardFrame = cva('rounded-xl border border-gray-200 bg-white');

export const cardImageWrapper = cva('relative rounded-xl overflow-hidden');

export 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' },
});

export const cardInfoLayout = cva('flex flex-nowrap items-start tablet:items-center gap-1.5');

export const cardInfoText = cva('text-caption tablet:text-body-s', {
variants: {
status: {
open: 'text-gray-500',
inactive: 'text-gray-300',
},
},
defaultVariants: { status: 'open' },
});

export const cardInfoIcon = cva('', {
variants: {
status: {
open: 'bg-red-300',
inactive: 'bg-gray-300',
},
},
defaultVariants: { status: 'open' },
});

export const cardPayLayout = cva('flex items-center gap-x-3');

export const cardBadge = cva('flex items-center gap-x-0.5 rounded-full');

export const cardBadgeText = cva('whitespace-nowrap text-caption tablet:text-body-s');

export const cardLayout = {
frame: cardFrame,
imageWrapper: cardImageWrapper,
heading: cardHeading,
infoLayout: cardInfoLayout,
info: cardInfoText,
infoIcon: cardInfoIcon,
payLayout: cardPayLayout,
badge: cardBadge,
badgeText: cardBadgeText,
};
export type CardStatusVariant = VariantProps<typeof cardHeading>['status'];
2 changes: 2 additions & 0 deletions src/components/ui/card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Notice } from '@/components/ui/card/notice/notice';
export { default as Post } from '@/components/ui/card/post/post';
15 changes: 15 additions & 0 deletions src/components/ui/card/notice/components/noticeHeader.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className={className}>
<p className={noticeLabel()}>{category}</p>
<h2 className={cardLayout.heading({ size: 'lg' })}>{name}</h2>
</div>
);
export default NoticeHeader;
20 changes: 20 additions & 0 deletions src/components/ui/card/notice/components/noticeImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { noticeImageWrapper } from '@/components/ui/card/notice/notice.styles';
import Image from 'next/image';

interface NoticeImageProps {
name?: string;
imageUrl?: string;
}
const NoticeImage = ({ name, imageUrl }: NoticeImageProps) => (
<div className={noticeImageWrapper()}>
<Image
src={imageUrl ?? ''}
alt={`${name} 가게 이미지`}
fill
sizes='(max-width: 744px) 630px, 540px'
className='object-cover'
priority
/>
</div>
);
export default NoticeImage;
91 changes: 91 additions & 0 deletions src/components/ui/card/notice/components/noticeInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { cardLayout } from '@/components/ui/card/card.styles';
import { NoticeVariant } from '@/components/ui/card/notice/notice';
import {
badgeText,
noticeButton,
noticeInfoWrapper,
noticeLabel,
payBadge,
} from '@/components/ui/card/notice/notice.styles';
import { Icon } from '@/components/ui/icon';
import { calcPayIncreasePercent } from '@/lib/utils/calcPayIncrease';
import { getTime } from '@/lib/utils/dateFormatter';
import { formatNumber } from '@/lib/utils/formatNumber';
import { NoticeCard } from '@/types/notice';
import { ReactNode } from 'react';
import NoticeHeader from './noticeHeader';

interface NoticeInfoProps<T extends Partial<NoticeCard>> {
value: T;
variant: NoticeVariant;
buttonComponent: ReactNode;
}

const NoticeInfo = <T extends Partial<NoticeCard>>({
value,
variant,
buttonComponent,
}: NoticeInfoProps<T>) => {
const {
name,
category,
hourlyPay,
originalHourlyPay,
startsAt,
workhour,
address1,
shopDescription,
} = value;
const payIncreasePercent = calcPayIncreasePercent(hourlyPay ?? 0, originalHourlyPay ?? 0);
const { date, startTime, endTime } = getTime(startsAt ?? '', workhour ?? 0);
const payIncreaseLabel =
payIncreasePercent && (payIncreasePercent > 100 ? '100% 이상' : `${payIncreasePercent}%`);
return (
<div className={noticeInfoWrapper()}>
<ul className='flex flex-col gap-3'>
{variant === 'notice' && (
<>
<li>
<p className={noticeLabel()}>시급</p>
<div className={cardLayout.payLayout()}>
<span className='text-heading-s font-bold tracking-wide'>
{formatNumber(hourlyPay ?? 0)}원
</span>
{payIncreasePercent !== null && (
<div className={payBadge()}>
<span className={badgeText()}>기존 시급 {payIncreaseLabel}</span>
<Icon
iconName='arrowUp'
iconSize='sm'
bigScreenSize='rg'
decorative
className='self-start bg-white'
/>
</div>
)}
</div>
</li>
<li className={cardLayout.infoLayout()}>
<Icon iconName='clock' iconSize='sm' ariaLabel='근무시간' className='bg-red-300' />
<p className={cardLayout.info()}>
{date} {startTime} ~ {endTime} ({workhour}시간)
</p>
</li>
</>
)}
{variant === 'shop' && (
<li>
<NoticeHeader name={name} category={category} className='mt-4' />
</li>
)}
<li className={cardLayout.infoLayout()}>
<Icon iconName='map' iconSize='sm' ariaLabel='근무위치' className='bg-red-300' />
<p className={cardLayout.info()}>{address1}</p>
</li>
<li className='text-body-l'>{shopDescription}</li>
</ul>
<div className={noticeButton()}>{buttonComponent}</div>
</div>
);
};
export default NoticeInfo;
76 changes: 76 additions & 0 deletions src/components/ui/card/notice/mockData/mockData.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
76 changes: 76 additions & 0 deletions src/components/ui/card/notice/mockData/noticeWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Notice notice={notice}>
<Button
as={Link}
href={href}
size='xs38'
full
className='font-bold'
variant={status !== 'open' ? 'disabled' : 'primary'}
>
{status !== 'open' ? '신청 불가' : '신청하기'}
</Button>
Comment on lines +56 to +65
Copy link
Contributor

Choose a reason for hiding this comment

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

조건문을 변수로 사용하는 건 어떨까요? (제안)

const isOpen = status === 'open';
const statusText = isOpen ? '신청하기' : '신청 불가';
const statusVariant = isOpen ? 'primary' : 'disabled'; 

Copy link
Contributor Author

Choose a reason for hiding this comment

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

아 이부분은 실제로 사용하는건 아니고 사용법을 보여드리기위한 목업컴포넌트라 추후 삭제할 예정입니다 :D

</Notice>
<Notice notice={shopItem} variant='shop'>
<Button as={Link} href={href} size='xs38' full className='font-bold'>
가게 편집하기
</Button>
</Notice>
</>
);
};

export default NoticeWrapper;
Loading