diff --git a/.gitignore b/.gitignore index 9044f9ea..b032ebe3 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,5 @@ coverage/ .vscode/ .vscode/settings.json +#monorepo issues +monorepo-docs/ \ No newline at end of file diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 8c62797f..346cc118 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -64,7 +64,7 @@ const nextConfig = { protocol: 'https', hostname: 'dreampaste-soso-image-storage.s3.ap-northeast-2.amazonaws.com', - pathname: '/freeboard/**', + pathname: '/**', // S3 버킷의 모든 경로 허용 (freeboard, voteboard 등) }, ], }, diff --git a/apps/web/package.json b/apps/web/package.json index 7f73a9df..f389374f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev", - "dev:https": "node server.mjs", + "dev": "node server.mjs", + "dev:http": "next dev", "build": "next build", "build:analyze": "node analyze.mjs", "start": "next start", @@ -29,6 +29,7 @@ "axios": "^1.10.0", "chart.js": "^4.5.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "js-cookie": "^3.0.5", "keen-slider": "^6.8.6", "lucide-react": "^0.525.0", diff --git a/apps/web/src/app/main/community/constants/sortOptions.ts b/apps/web/src/app/main/community/constants/sortOptions.ts index 6e317bf6..f2177b61 100644 --- a/apps/web/src/app/main/community/constants/sortOptions.ts +++ b/apps/web/src/app/main/community/constants/sortOptions.ts @@ -3,4 +3,5 @@ export const SORT_OPTIONS: SortOption[] = [ { label: '최신순', value: 'LATEST' }, { label: '인기순', value: 'LIKE' }, { label: '댓글순', value: 'COMMENT' }, + { label: '조회순', value: 'VIEW' }, ]; diff --git a/apps/web/src/app/main/community/constants/votesOptions.tsx b/apps/web/src/app/main/community/constants/votesOptions.tsx new file mode 100644 index 00000000..f1a76007 --- /dev/null +++ b/apps/web/src/app/main/community/constants/votesOptions.tsx @@ -0,0 +1,9 @@ +import { GetVotePostListStatus } from '@/generated/api/models'; +import { TabItem } from '@/types/tab.types'; + +export type VoteState = GetVotePostListStatus | null; + +export const VOTE_STATES: TabItem[] = [ + { label: '진행중', value: 'IN_PROGRESS' }, + { label: '완료', value: 'COMPLETED' }, +]; diff --git a/apps/web/src/app/main/community/freeboard/ClientPage.tsx b/apps/web/src/app/main/community/freeboard/ClientPage.tsx index 85439974..0018c64a 100644 --- a/apps/web/src/app/main/community/freeboard/ClientPage.tsx +++ b/apps/web/src/app/main/community/freeboard/ClientPage.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; +import { useOverlay } from '@/hooks/ui/useOverlay'; import { PillChipsTab } from '@/components/tabs/PillChipsTab'; import { CATEGORIES, Category } from '../constants/categories'; import { SortHeader } from '../components/SortHeader'; @@ -9,6 +10,7 @@ import { SortValue } from '@/types/options.types'; import { SORT_OPTIONS } from '../constants/sortOptions'; import FloatingButton from '@/components/buttons/FloatingButton'; import { FreeBoardCard } from '../components/FreeboardCard'; +import FloatingCategoryMenu from '@/components/buttons/FloatingCategoryMenu'; import CommunityPostList from '../components/CommunityPostList'; import { FreeboardSummary } from '@/generated/api/models'; import { @@ -28,7 +30,7 @@ import { export default function FreeboardClientPage() { const [category, setCategory] = useState(null); const [sortOption, setSortOption] = useState('LATEST'); - + const { open } = useOverlay(); // 무한스크롤 데이터 페칭 const { data, @@ -66,6 +68,22 @@ export default function FreeboardClientPage() { // 총 게시글 개수 const totalCount = data?.pages[0]?.totalCount ?? 0; + const handleFloatingButtonClick = () => { + open( + ({ close }) => ( + close(null, { duration: 200 })} + /> + ), + { + backdrop: true, + closeOnBackdrop: true, + }, + ); + }; + return (
@@ -96,7 +114,7 @@ export default function FreeboardClientPage() { )} storageKey="freeboard-post-list-scroll" /> - +
); } diff --git a/apps/web/src/app/main/community/votesboard/ClientPage.tsx b/apps/web/src/app/main/community/votesboard/ClientPage.tsx new file mode 100644 index 00000000..395325d5 --- /dev/null +++ b/apps/web/src/app/main/community/votesboard/ClientPage.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useState } from 'react'; +import { useOverlay } from '@/hooks/ui/useOverlay'; +import { SortHeader } from '../components/SortHeader'; +import { SORT_OPTIONS } from '../constants/sortOptions'; +import { SortValue } from '@/types/options.types'; +import { PillChipsTab } from '@/components/tabs/PillChipsTab'; +import { CATEGORIES } from '../constants/categories'; +import { VOTE_STATES, VoteState } from '../constants/votesOptions'; +import { VoteboardSummary } from '@/generated/api/models'; +import FloatingCategoryMenu from '@/components/buttons/FloatingCategoryMenu'; +import CommunityPostList from '../components/CommunityPostList'; +import { VoteBoardCard } from './components/VoteBoardCard'; +import { + getVotePostsByCursor, + getGetVotePostsByCursorQueryKey, +} from '@/generated/api/endpoints/voteboard/voteboard'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import FloatingButton from '@/components/buttons/FloatingButton'; +/** + * 투표 게시판 클라이언트 메인 페이지 + * + * @description + * - [전체/진행중/완료] 상태 탭 제공 + * - 상태별 투표 게시글 목록 표시 + * - 정렬 옵션 제공 + * + */ + +export default function VotesboardClientPage() { + const [sortOption, setSortOption] = useState('LATEST'); + const [voteState, setVoteState] = useState(null); + const { open } = useOverlay(); + // 무한스크롤 데이터 페칭 + const { + data, + fetchNextPage, + hasNextPage, + isLoading, + isFetchingNextPage, + error, + refetch, + } = useInfiniteQuery({ + queryKey: getGetVotePostsByCursorQueryKey({ + status: voteState ?? undefined, + sort: sortOption, + }), + queryFn: ({ pageParam, signal }) => + getVotePostsByCursor( + { + status: voteState ?? undefined, + sort: sortOption, + cursor: pageParam, + size: 10, + }, + signal, + ), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => { + return lastPage.hasNext ? lastPage.nextCursor : undefined; + }, + }); + const allVotePosts: VoteboardSummary[] = + data?.pages.flatMap((page) => page.posts ?? []) ?? []; + const totalCount = data?.pages[0]?.totalCount ?? 0; + + const handleFloatingButtonClick = () => { + open( + ({ close }) => ( + close(null, { duration: 200 })} + /> + ), + { + backdrop: true, + closeOnBackdrop: true, + }, + ); + }; + + return ( +
+ + chips={VOTE_STATES} + showAll + activeValue={voteState} + onChange={setVoteState} + ariaLabel="투표 상태 선택 필터" + /> + {/* 필터 헤더 */} + + + + items={allVotePosts} + hasNextPage={hasNextPage || false} + fetchNextPage={fetchNextPage} + isFetchingNextPage={isFetchingNextPage} + initialLoading={isLoading} + error={error} + onRetry={() => refetch()} + storageKey="votesboard-post-list-scroll" + getItemKey={(post, index) => post.postId ?? `post-${index}`} + renderItem={(post) => } + /> + + {/* TODO: FloatingButton 추가 */} + +
+ ); +} diff --git a/apps/web/src/app/main/community/votesboard/components/VoteBoardCard.tsx b/apps/web/src/app/main/community/votesboard/components/VoteBoardCard.tsx new file mode 100644 index 00000000..7c44ab97 --- /dev/null +++ b/apps/web/src/app/main/community/votesboard/components/VoteBoardCard.tsx @@ -0,0 +1,104 @@ +// src/components/CommunityCard.tsx +import Image from 'next/image'; +import Card from '@/components/Card'; +import { CategoryChip } from '@/components/chips/CategoryChip'; +import { Category } from '../../constants/categories'; +import { LaptopMinimalCheck, MessageSquareMore } from 'lucide-react'; +//import { useRouter } from 'next/navigation'; +import { formatCount } from '@/utils/formatCount'; +import type { VoteboardSummary } from '@/generated/api/models'; +import { formatVoteDeadline } from '@/utils/vote-deadline'; +import { VoteStatusChip } from '@/components/chips/VoteStatusChip'; +import { cn } from '@/utils/cn'; +export interface VoteBoardCardProps { + post: VoteboardSummary; +} + +export function VoteBoardCard({ post }: VoteBoardCardProps) { + const { + postId, + title, + contentPreview, + category, + thumbnailUrl, + commentCount, + totalVotes, + voteStatus, + endTime, + hasVoted, + } = post; + + // const router = useRouter(); + + const handleOnClick = () => { + console.warn( + '추후 투표 상세 페이지로 이동할 예정입니다. => postId:', + postId, + ); + //router.push(`/main/community/votesboard/${postId}`); + }; + + return ( + +
+ + +
+ +
+
+

+ {title} +

+

+ {contentPreview} +

+
+ {thumbnailUrl && ( + 게시글 썸네일 이미지 + )} +
+ +
+ {/* 남은시간 */} + + {formatVoteDeadline(endTime)} + +
+ {/* 득표수 */} +
+ + {formatCount(totalVotes)} +
+ {/* 댓글 */} +
+ + + {formatCount(commentCount)} + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/main/community/votesboard/page.tsx b/apps/web/src/app/main/community/votesboard/page.tsx index 9ca856b3..1ba43bf5 100644 --- a/apps/web/src/app/main/community/votesboard/page.tsx +++ b/apps/web/src/app/main/community/votesboard/page.tsx @@ -1,51 +1,46 @@ -'use client'; - -import { useState } from 'react'; -import { SortHeader } from '../components/SortHeader'; -import { SORT_OPTIONS } from '../constants/sortOptions'; -import { SortValue } from '@/types/options.types'; +import { + getGetVotePostsByCursorQueryKey, + getVotePostsByCursor, +} from '@/generated/api/endpoints/voteboard/voteboard'; +import { + HydrationBoundary, + QueryClient, + dehydrate, +} from '@tanstack/react-query'; +import ClientPage from './ClientPage'; /** - * 투표 게시판 메인 페이지 + * 투표 게시판 메인 페이지( 서버 컴포넌트) * * @description - * - [전체/진행중/완료] 상태 탭 제공 - * - 상태별 투표 게시글 목록 표시 - * - 정렬 옵션 제공 - * - * @todo - * - 투표 게시글 API 연동 - * - 무한스크롤 구현 - * - 투표 카드 컴포넌트 구현 + * - 전체 카테고리 + * - 최신순 정렬 + * - 10개 게시글 프리패치 */ -export default function VotesboardPage() { - const [sortOption, setSortOption] = useState( - SORT_OPTIONS[0].value, - ); - - return ( -
- {/* 필터 헤더 */} - +export default async function VotesboardPage() { + const queryClient = new QueryClient(); - {/* 게시글 목록 */} -
-
-

- 투표 게시글이 없습니다. -
- TODO: 투표 목록 구현 예정 -

-
-
+ await queryClient.prefetchInfiniteQuery({ + queryKey: getGetVotePostsByCursorQueryKey({ + status: undefined, + sort: 'LATEST', + }), + queryFn: async ({ signal }) => + getVotePostsByCursor( + { status: undefined, sort: 'LATEST', size: 10 }, + signal, + ), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => { + return lastPage.hasNext ? lastPage.nextCursor : undefined; + }, + pages: 1, + }); - {/* TODO: FloatingButton 추가 */} -
+ return ( + + + ); } diff --git a/apps/web/src/components/buttons/FloatingButton.tsx b/apps/web/src/components/buttons/FloatingButton.tsx index f40bb350..f351e07d 100644 --- a/apps/web/src/components/buttons/FloatingButton.tsx +++ b/apps/web/src/components/buttons/FloatingButton.tsx @@ -1,52 +1,25 @@ 'use client'; import { twMerge } from 'tailwind-merge'; -import { useOverlay } from '@/hooks/ui/useOverlay'; -import FloatingMenu, { type CategoryItem } from './FloatingMenu'; import { PenLine } from 'lucide-react'; /** * FloatingButton 컴포넌트 Props */ export interface FloatingButtonProps { - /** 카테고리 목록 */ - categories: CategoryItem[]; + /** 클릭 이벤트 핸들러 */ + onClick?: () => void; /** 추가 CSS 클래스명 */ className?: string; } /** * 플로팅 액션 버튼 컴포넌트 - * - * @description 카테고리 메뉴를 오버레이로 표시하는 플로팅 버튼 - * - 클릭 시 useOverlay를 통해 FloatingMenu 표시 - * - 백드롭 클릭으로 메뉴 닫기 가능 - * - 접근성 속성 완전 지원 */ export default function FloatingButton({ - categories, className, + onClick, }: FloatingButtonProps) { - const { open } = useOverlay(); - - /** - * 메뉴 열기 함수 (Promise 기반) - */ - const handleOpenMenu = () => { - open( - ({ close }) => ( - close(null, { duration: 200 })} - /> - ), - { - backdrop: true, - closeOnBackdrop: true, - }, - ); - }; - return ( diff --git a/apps/web/src/components/buttons/FloatingMenu.tsx b/apps/web/src/components/buttons/FloatingCategoryMenu.tsx similarity index 94% rename from apps/web/src/components/buttons/FloatingMenu.tsx rename to apps/web/src/components/buttons/FloatingCategoryMenu.tsx index be0aa17f..637eb519 100644 --- a/apps/web/src/components/buttons/FloatingMenu.tsx +++ b/apps/web/src/components/buttons/FloatingCategoryMenu.tsx @@ -4,9 +4,10 @@ import { useEffect, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import Pressable from '../Pressable'; -import { useRouter, useParams } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { X } from 'lucide-react'; +export type CommunityRoute = 'freeboard' | 'votesboard'; /** * 카테고리 아이템 타입 정의 */ @@ -20,9 +21,10 @@ export interface CategoryItem { /** * FloatingMenu 컴포넌트 Props */ -export interface FloatingMenuProps { +export interface FloatingCategoryMenuProps { /** 카테고리 목록 */ categories: CategoryItem[]; + route: CommunityRoute; /** 추가 CSS 클래스명 */ className?: string; /** 닫기 콜백 함수 (애니메이션 포함) */ @@ -38,17 +40,17 @@ export interface FloatingMenuProps { * - CSS 기반 fade-in 애니메이션 * - 메뉴 아이템 클릭 시 자동 닫기 및 페이지 이동 */ -export default function FloatingMenu({ +export default function FloatingCategoryMenu({ categories, className, + route, onClose, -}: FloatingMenuProps) { +}: FloatingCategoryMenuProps) { const menuRef = useRef(null); const [pressedButton, setPressedButton] = useState( null, ); const router = useRouter(); - const params = useParams(); /** * 컴포넌트 마운트 시 첫 번째 버튼에 포커스 */ @@ -97,10 +99,7 @@ export default function FloatingMenu({ const handleButtonClick = (value: string) => { onClose(); - const currentTab = params.tab || 'freeboard'; - router.push( - `/main/community/${currentTab}/new?category=${value}`, - ); + router.push(`/main/community/${route}/new?category=${value}`); }; /** diff --git a/apps/web/src/components/chips/VoteStatusChip.tsx b/apps/web/src/components/chips/VoteStatusChip.tsx new file mode 100644 index 00000000..68c91c81 --- /dev/null +++ b/apps/web/src/components/chips/VoteStatusChip.tsx @@ -0,0 +1,106 @@ +import { VotePostSummaryResponseVoteStatus } from '@/generated/api/models/votePostSummaryResponseVoteStatus'; +import { twMerge } from 'tailwind-merge'; + +/** + * 투표 상태 칩 타입 + * - in-progress: 진행 중 + * - deadline-soon: 마감 임박 + * - closed: 마감 + * - completed: 완료 + */ +export type VoteChipType = + | 'in-progress' + | 'deadline-soon' + | 'closed' + | 'completed'; + +// 투표 상태 컬러 +const VOTE_STATUS_COLORS: Record = { + 'in-progress': 'bg-blue-100 text-blue-800', + 'deadline-soon': 'bg-orange-100 text-orange-800', + closed: 'bg-gray-100 text-gray-800', + completed: 'bg-green-100 text-green-800', +}; + +// 투표 상태 라벨 +const VOTE_STATUS_LABELS: Record = { + 'in-progress': '진행 중', + 'deadline-soon': '마감 임박', + closed: '마감', + completed: '완료', +}; + +/** + * 투표 상태 타입 반환 + */ +export function getVoteChipType( + voteStatus: VotePostSummaryResponseVoteStatus, + endTime: string, +): VoteChipType { + if ( + voteStatus === VotePostSummaryResponseVoteStatus.COMPLETED || + voteStatus === VotePostSummaryResponseVoteStatus.DELETED + ) { + return 'completed'; + } + + if (voteStatus === VotePostSummaryResponseVoteStatus.IN_PROGRESS) { + const now = new Date(); + const endDate = new Date(endTime); + + // 투표 마감 시간 비교 + if (endDate <= now) { + return 'closed'; + } + + // 24시간 이내 마감 임박 (마감 임박) + const hoursUntilDeadline = + (endDate.getTime() - now.getTime()) / (1000 * 60 * 60); + if (hoursUntilDeadline <= 24) { + return 'deadline-soon'; + } + + // 진행 중 + return 'in-progress'; + } + + // 기본값 + return 'in-progress'; +} + +/** + * 투표 상태 라벨 반환 + */ +export function getVoteChipLabel(statusType: VoteChipType): string { + return VOTE_STATUS_LABELS[statusType]; +} + +/** + * 투표 상태 색상 반환 + */ +export function getVoteChipColor(statusType: VoteChipType): string { + return VOTE_STATUS_COLORS[statusType]; +} + +interface VoteStatusChipProps { + voteStatus: VotePostSummaryResponseVoteStatus; + endTime: string; +} + +export function VoteStatusChip({ + voteStatus, + endTime, +}: VoteStatusChipProps) { + const statusType = getVoteChipType(voteStatus, endTime); + + return ( + + {getVoteChipLabel(statusType)} + + ); +} diff --git a/apps/web/src/generated/api/models/votePostDetailResponse.ts b/apps/web/src/generated/api/models/votePostDetailResponse.ts index 0a3c2dbb..187e3ac1 100644 --- a/apps/web/src/generated/api/models/votePostDetailResponse.ts +++ b/apps/web/src/generated/api/models/votePostDetailResponse.ts @@ -29,6 +29,8 @@ export interface VotePostDetailResponse { images: ImageInfo[]; /** 투표 옵션 목록 */ voteOptions: VoteOptionResponse[]; + /** 현재 사용자의 투표 참여 여부 (비인증 사용자인 경우 null, 참여하지 않은 경우 false, 참여한 경우 true) */ + hasVoted: boolean; /** 현재 사용자가 선택한 옵션 ID 목록 (미투표 시 빈 리스트) */ selectedOptionIds?: number[]; /** 총 투표 참여자 수 */ diff --git a/apps/web/src/generated/api/models/votePostListResponse.ts b/apps/web/src/generated/api/models/votePostListResponse.ts index f54dc442..e77fd974 100644 --- a/apps/web/src/generated/api/models/votePostListResponse.ts +++ b/apps/web/src/generated/api/models/votePostListResponse.ts @@ -13,10 +13,15 @@ import type { VotePostSummaryResponse } from './votePostSummaryResponse'; export interface VotePostListResponse { /** 투표 게시글 목록 */ posts: VotePostSummaryResponse[]; - /** 다음 커서 (다음 페이지 조회용, 없으면 null) */ - nextCursor?: string; /** 다음 페이지 존재 여부 */ hasNext: boolean; - /** 현재 페이지 게시글 수 */ + /** 다음 페이지를 위한 커서 값 */ + nextCursor?: string; + /** 현재 페이지 크기 */ size: number; + /** 총 게시글 수 */ + totalCount: number; + authorized?: boolean; + /** 요청한 사용자가 인증되었는지 여부 (액세스 토큰 제공 여부) */ + isAuthorized: boolean; } diff --git a/apps/web/src/generated/api/models/votePostSummaryResponse.ts b/apps/web/src/generated/api/models/votePostSummaryResponse.ts index 61462543..8fd347b3 100644 --- a/apps/web/src/generated/api/models/votePostSummaryResponse.ts +++ b/apps/web/src/generated/api/models/votePostSummaryResponse.ts @@ -22,6 +22,8 @@ export interface VotePostSummaryResponse { category: VotePostSummaryResponseCategory; /** 게시글 제목 */ title: string; + /** 내용 미리보기 (100자 제한) */ + contentPreview: string; /** 첫 번째 이미지 URL (썸네일용) */ thumbnailUrl?: string; /** 이미지 개수 */ @@ -32,6 +34,8 @@ export interface VotePostSummaryResponse { commentCount: number; /** 총 투표 참여자 수 */ totalVotes: number; + /** 현재 사용자의 투표 참여 여부 (비인증 사용자인 경우 null, 참여하지 않은 경우 false, 참여한 경우 true) */ + hasVoted: boolean; /** 투표 상태 (IN_PROGRESS: 진행중, COMPLETED: 완료) */ voteStatus: VotePostSummaryResponseVoteStatus; /** 투표 마감 시간 */ @@ -48,7 +52,6 @@ export interface VotePostSummaryResponse { createdDate: string; /** 수정일시 */ lastModifiedDate: string; - liked?: boolean; - /** 현재 사용자의 좋아요 여부 (비로그인 시 false) */ + /** 현재 사용자의 좋아요 여부 (비인증 사용자인 경우 null) */ isLiked: boolean; } diff --git a/apps/web/src/types/options.types.ts b/apps/web/src/types/options.types.ts index 1b863dbd..5a6cc294 100644 --- a/apps/web/src/types/options.types.ts +++ b/apps/web/src/types/options.types.ts @@ -2,7 +2,7 @@ * 드롭다운에서 선택할 수 있는 정렬 옵션의 타입 정의 * */ -export type SortValue = 'LATEST' | 'LIKE' | 'COMMENT'; +export type SortValue = 'LATEST' | 'LIKE' | 'COMMENT' | 'VIEW'; export interface SortOption { label: string; value: SortValue; diff --git a/apps/web/src/utils/vote-deadline.ts b/apps/web/src/utils/vote-deadline.ts new file mode 100644 index 00000000..ec4c4728 --- /dev/null +++ b/apps/web/src/utils/vote-deadline.ts @@ -0,0 +1,51 @@ +import { + format, + differenceInDays, + differenceInHours, + differenceInMinutes, +} from 'date-fns'; + +/** + * 투표 마감 시간을 사용자 친화적인 형식으로 변환 + * + * 규칙: + * - 7일 초과: "25.12.25 마감" (날짜 표시) + * - 7일 이내: "7일 후 마감", "6일 후 마감" + * - 하루 이내: "12시간 후 마감", "20시간 후 마감" + * - 1시간 이내: "30분 후 마감", "45분 후 마감" + * - 마감된 경우: "마감됨" + * + * @param endTime - ISO 형식의 마감 시간 문자열 + * @returns 포맷된 마감 시간 문자열 + */ +export function formatVoteDeadline(endTime: string): string { + const now = new Date(); + const endDate = new Date(endTime); + + // 이미 마감된 경우 + if (endDate <= now) { + return '마감됨'; + } + + const daysLeft = differenceInDays(endDate, now); + const hoursLeft = differenceInHours(endDate, now); + const minutesLeft = differenceInMinutes(endDate, now); + + // 7일 초과: 날짜 표시 (YY.MM.DD 형식) + if (daysLeft > 7) { + return `${format(endDate, 'yy.MM.dd')} 마감`; + } + + // 7일 이내: 일 단위 표시 + if (daysLeft >= 1) { + return `${daysLeft}일 후 마감`; + } + + // 하루 이내: 시간 단위 표시 + if (hoursLeft >= 1) { + return `${hoursLeft}시간 후 마감`; + } + + // 1시간 이내: 분 단위 표시 + return `${minutesLeft}분 후 마감`; +} diff --git a/package.json b/package.json index 43dbea5c..84fe647f 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "packageManager": "pnpm@9.15.9", "scripts": { "dev": " turbo run dev --parallel", - "dev:storybook": "pnpm --filter @soso/storybook dev", - "dev:docs": "pnpm --filter @soso/docs dev", + "dev:web": "pnpm --filter web dev:https", + "dev:docs": "pnpm --filter docs dev", "build": " turbo run build", "build:packages": "turbo run build --filter='./packages/*'", "lint": " turbo run lint", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 700a1cb4..dea4b713 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -3308,6 +3311,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -10308,6 +10314,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.1.0: {} + debounce@1.2.1: {} debug@2.6.9: