diff --git a/next.config.mjs b/next.config.mjs index 776efd8..a76190d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,6 +8,11 @@ const nextConfig = { hostname: 'bootcamp-project-api.s3.ap-northeast-2.amazonaws.com', pathname: '/**', }, + { + protocol: 'https', + hostname: 'picsum.photos', + pathname: '/**', + }, ], }, }; diff --git a/src/components/ui/icon/icon.tsx b/src/components/ui/icon/icon.tsx index d25bd3a..a845e4e 100644 --- a/src/components/ui/icon/icon.tsx +++ b/src/components/ui/icon/icon.tsx @@ -1,10 +1,17 @@ -import { ICONS, ICON_SIZES, type IconName, type IconSize } from '@/constants/icon'; +import { + ICONS, + ICON_RESPONSIVE_SIZES, + ICON_SIZES, + type IconName, + type IconResponsiveSize, + type IconSize, +} from '@/constants/icon'; import { cn } from '@/lib/utils/cn'; import { forwardRef } from 'react'; interface IconProps extends React.HTMLAttributes { iconName: IconName; iconSize?: IconSize; // 모바일 기본 사이즈 - bigScreenSize?: IconSize; // PC에서 사이즈 다를때 사용 + bigScreenSize?: IconResponsiveSize; // PC에서 사이즈 다를때 사용 className?: string; ariaLabel: string; // 접근성 라벨 decorative?: boolean; @@ -32,7 +39,7 @@ const Icon = forwardRef( ) => { const url = ICONS[iconName]; const size = ICON_SIZES[iconSize]; - const bigSize = bigScreenSize ? `tablet:${ICON_SIZES[bigScreenSize]}` : ''; + const bigSize = bigScreenSize ? ICON_RESPONSIVE_SIZES[bigScreenSize] : ''; return ( ; + +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, + href: '/notices/notice-002', + }, + }, +}; + +export const Closed: Story = { + args: { + notice: { + ...baseNotice, + id: 'notice-003', + closed: true, + hourlyPay: 9500, + originalHourlyPay: 9000, + startsAt: '2023-07-01T09:00:00Z', + href: '/notices/notice-003', + }, + }, +}; diff --git a/src/components/ui/post/post.styles.ts b/src/components/ui/post/post.styles.ts new file mode 100644 index 0000000..4efcdea --- /dev/null +++ b/src/components/ui/post/post.styles.ts @@ -0,0 +1,83 @@ +// src/components/post.styles.ts +import { cva, type VariantProps } from 'class-variance-authority'; + +export const postWrapper = cva( + 'block rounded-xl border border-gray-200 p-3 tablet:rounded-2xl tablet:p-4' +); + +export const postImageWrapper = cva( + 'relative rounded-xl overflow-hidden h-[120px] tablet:h-[160px]' +); + +export const postImageDimmed = cva( + 'absolute inset-0 flex items-center justify-center bg-modal-dimmed text-heading-s text-zinc-300 font-bold' +); + +export const postHeading = cva('text-heading-s font-bold', { + variants: { + status: { + open: '', + inactive: 'text-gray-300', + }, + }, + defaultVariants: { status: 'open' }, +}); + +export const workInfoLayout = cva('flex flex-nowrap items-start tablet:items-center gap-1.5'); + +export const workInfoText = cva('text-caption tablet:text-body-s', { + variants: { + status: { + open: 'text-gray-500', + inactive: 'text-gray-300', + }, + }, + defaultVariants: { status: 'open' }, +}); + +export const workInfoIcon = cva('', { + variants: { + status: { + open: 'bg-red-300', + inactive: 'bg-gray-300', + }, + }, + defaultVariants: { status: 'open' }, +}); + +export const workPayLayout = cva( + 'mt-4 flex flex-wrap items-center justify-between gap-x-3 tablet:flex-nowrap' +); + +export const postBadge = cva('flex items-center gap-x-0.5 rounded-full tablet:py-2 tablet:px-3', { + variants: { + status: { + open: 'tablet:bg-red-400', + inactive: 'tablet:bg-gray-200', + }, + }, + defaultVariants: { status: 'open' }, +}); + +export const badgeText = cva( + 'whitespace-nowrap text-caption tablet:text-body-s tablet:font-bold tablet:text-white', + { + variants: { + status: { + open: 'text-red-400', + inactive: 'text-gray-300', + }, + }, + defaultVariants: { status: 'open' }, + } +); +export const badgeIcon = cva('tablet:bg-white', { + variants: { + status: { + open: 'bg-red-400', + inactive: 'bg-gray-300', + }, + }, + defaultVariants: { status: 'open' }, +}); +export type PostStatusVariant = VariantProps['status']; diff --git a/src/components/ui/post/post.tsx b/src/components/ui/post/post.tsx new file mode 100644 index 0000000..a54366c --- /dev/null +++ b/src/components/ui/post/post.tsx @@ -0,0 +1,115 @@ +import { Icon } from '@/components/ui/icon'; +import { calcPayIncreasePercent } from '@/lib/utils/calcPayIncrease'; +import { cn } from '@/lib/utils/cn'; +import { formatNumber } from '@/lib/utils/formatNumber'; +import { getTime } from '@/lib/utils/getTime'; +import type { PostCard } from '@/types/notice'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useMemo } from 'react'; +import { + badgeIcon, + badgeText, + postBadge, + postHeading, + postImageDimmed, + postImageWrapper, + postWrapper, + workInfoIcon, + workInfoLayout, + workInfoText, + workPayLayout, + type PostStatusVariant, +} from './post.styles'; +type NoticeStatus = 'open' | 'expired' | 'closed'; + +interface PostProps { + notice: PostCard; +} +const STATUS_LABEL = { + expired: '지난 공고', + closed: '공고 마감', +} as const; + +const hasShiftStarted = (startsAt: string) => Date.now() >= new Date(startsAt).getTime(); + +const getNoticeStatus = (closed: boolean, startsAt: string): NoticeStatus => { + if (closed) return 'closed'; + return hasShiftStarted(startsAt) ? 'expired' : 'open'; +}; + +const Post = ({ notice }: PostProps) => { + const { + hourlyPay, + startsAt, + workhour, + closed, + originalHourlyPay, + imageUrl, + name, + address1, + href, + } = notice; + const status = useMemo(() => getNoticeStatus(closed, startsAt), [closed, startsAt]); + const payIncreasePercent = useMemo( + () => calcPayIncreasePercent(hourlyPay, originalHourlyPay), + [hourlyPay, originalHourlyPay] + ); + const { date, startTime, endTime } = getTime(startsAt, workhour); + const statusVariant: PostStatusVariant = status === 'open' ? 'open' : 'inactive'; + const payIncreaseLabel = + payIncreasePercent && (payIncreasePercent > 100 ? '100% 이상' : `${payIncreasePercent}%`); + return ( + +
+ {`${name} + {status !== 'open' &&
{STATUS_LABEL[status]}
} +
+
+
    +
  • {name}
  • +
  • + +

    + {date} {startTime} ~ {endTime} ({workhour}시간) +

    +
  • +
  • + +

    {address1}

    +
  • +
+
+ + {formatNumber(hourlyPay)}원 + + {payIncreasePercent !== null && ( +
+ + 기존 시급 {payIncreaseLabel} + + +
+ )} +
+
+ + ); +}; +export default Post; diff --git a/src/components/ui/post/postWrapper.tsx b/src/components/ui/post/postWrapper.tsx new file mode 100644 index 0000000..b8927eb --- /dev/null +++ b/src/components/ui/post/postWrapper.tsx @@ -0,0 +1,40 @@ +import type { PostCard } from '@/types/notice'; +import mockResponse from './mockData.json'; +import Post from './post'; + +// mockData 용 페이지 + +type RawNotice = (typeof mockResponse)['items'][number]; + +const toPostCard = ({ item, links }: RawNotice): PostCard => { + const shop = item.shop.item; + const href = links.find(link => link.rel === 'self')?.href ?? item.shop.href; + + return { + id: item.id, + hourlyPay: item.hourlyPay, + startsAt: item.startsAt, + workhour: item.workhour, + description: item.description, + closed: item.closed, + name: shop.name, + address1: shop.address1, + imageUrl: shop.imageUrl, + originalHourlyPay: shop.originalHourlyPay, + href, + }; +}; + +const PostWrapper = () => { + const notices: PostCard[] = mockResponse.items.map(toPostCard); + + return ( +
+ {notices.map(notice => ( + + ))} +
+ ); +}; + +export default PostWrapper; diff --git a/src/constants/icon.ts b/src/constants/icon.ts index 58fed2d..783f222 100644 --- a/src/constants/icon.ts +++ b/src/constants/icon.ts @@ -63,3 +63,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/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..58c525f 100644 --- a/src/lib/utils/cn.ts +++ b/src/lib/utils/cn.ts @@ -1,11 +1,20 @@ 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'] }], + }, + }, +}); + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMergeCustom(clsx(inputs)); } 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/pages/_app.tsx b/src/pages/_app.tsx index 2328246..d71b207 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,7 +1,5 @@ import { Footer, Header, Wrapper } from '@/components/layout'; - import AppProvider from '@/context/appProvider'; - import '@/styles/fonts.css'; import '@/styles/globals.css'; import type { NextPage } from 'next'; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 6ff5373..a19827f 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,3 +1,9 @@ +import PostWrapper from '@/components/ui/post/postWrapper'; + export default function Home() { - return <>; + return ( + <> + + + ); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 2587117..bcd2122 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -35,6 +35,9 @@ --background: #f8f9fa; + --modal-frame: #f9f9f9; + --modal-dimmed: rgba(0, 0, 0, 0.7); + /* font size */ /* Caption */ @@ -71,8 +74,6 @@ --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 { diff --git a/src/types/notice.ts b/src/types/notice.ts index d2231c8..60a8202 100644 --- a/src/types/notice.ts +++ b/src/types/notice.ts @@ -1,14 +1,20 @@ -import { User } from './user'; +/* -------------------- 공고 -------------------- */ -export interface Notice { - id: string; +import { ShopSummary } from './shop'; + +// 공고 등록 +export interface NoticeBase { hourlyPay: number; - description: string; - startsAt: string; + startsAt: string; // RFC 3339 workhour: number; + description: string; +} + +// 공고 목록 및 알림 목록 +export interface Notice extends NoticeBase { + id: string; closed: boolean; - user?: { - item: User; - href?: string; - }; } + + +export type PostCard = Notice & ShopSummary & { href: string }; diff --git a/src/types/shop.ts b/src/types/shop.ts index 5e340cb..ac50676 100644 --- a/src/types/shop.ts +++ b/src/types/shop.ts @@ -1,6 +1,8 @@ import { User } from './user'; -export interface Shop { +/* -------------------- 가게 -------------------- */ +// 가게등록 +export interface ShopBase { id: string; name: string; category: string; @@ -9,8 +11,13 @@ export interface Shop { description: string; imageUrl: string; originalHourlyPay: number; +} +// 가게정보 조회 +export interface Shop extends ShopBase { user?: { item: User; - href?: string; + href: string; }; } + +export type ShopSummary = Pick;