diff --git a/package.json b/package.json index c6c9358f..234db76c 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.18", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.27.1", "googleapis": "^164.1.0", "lucide-react": "^0.475.0", "next": "15.2.8", @@ -106,8 +107,7 @@ "typescript-eslint": "^8.24.0", "vitest": "^3.1.1" }, - "resolutions": { + "resolutions": { "strip-ansi": "6.0.1" } - } diff --git a/src/app/(service)/(my)/notification/page.tsx b/src/app/(service)/(my)/notification/page.tsx index 7711187b..aa396981 100644 --- a/src/app/(service)/(my)/notification/page.tsx +++ b/src/app/(service)/(my)/notification/page.tsx @@ -1,6 +1,5 @@ 'use client'; -import NotificationIcon from 'public/images/notification.svg'; import { useState } from 'react'; import type { GetMemberNotificationsTopicTypeEnum } from '@/api/openapi/api/notification-api'; @@ -13,6 +12,7 @@ import { useGetNotificationCategories, useReadNotifications, } from '@/hooks/queries/notification-api'; +import NotificationIcon from 'public/images/notification.svg'; const READ_STATUS_OPTIONS = [ { value: 'all', label: '상태 전체' }, diff --git a/src/app/(service)/home/home-content.tsx b/src/app/(service)/home/home-content.tsx new file mode 100644 index 00000000..b20f2849 --- /dev/null +++ b/src/app/(service)/home/home-content.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; +import TabNavigation from '@/components/home/tab-navigation'; +import ArchiveTab from '@/features/study/one-to-one/archive/ui/archive-tab'; +import CommunityTab from '@/features/study/one-to-one/balance-game/ui/community-tab'; +import HallOfFameTab from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab'; +import StudyHistoryTab from '@/features/study/one-to-one/history/ui/study-history-tab'; +import StudyTab from '@/features/study/one-to-one/schedule/ui/home-study-tab'; + +interface HomeContentProps { + activeTab: string; +} + +export default function HomeContent({ activeTab }: HomeContentProps) { + const renderTabContent = () => { + switch (activeTab) { + case 'study': + return ; + case 'history': + return ; + case 'ranking': + return ; + case 'archive': + return ; + case 'community': + return ; + default: + return ; + } + }; + + return ( + <> + + + 로딩 중... + + } + > + {renderTabContent()} + + + ); +} diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx index 4a653e5a..b54d5980 100644 --- a/src/app/(service)/home/page.tsx +++ b/src/app/(service)/home/page.tsx @@ -1,9 +1,9 @@ import { Metadata } from 'next'; import StartStudyButton from '@/components/home/start-study-button'; -import StudyCard from '@/features/study/schedule/ui/study-card'; import { generateMetadata as generateSEOMetadata } from '@/utils/seo'; import Banner from '@/widgets/home/banner'; import FeedbackLink from '@/widgets/home/feedback-link'; +import HomeContent from './home-content'; export const metadata: Metadata = generateSEOMetadata({ title: '홈 - ZERO-ONE', @@ -14,15 +14,20 @@ export const metadata: Metadata = generateSEOMetadata({ canonicalUrl: 'https://www.zeroone.it.kr/home', }); -export default async function Home() { +export default async function Home({ + searchParams, +}: { + searchParams?: Promise<{ tab?: string }>; +}) { + const resolvedSearchParams = await searchParams; + const activeTab = resolvedSearchParams?.tab || 'study'; + return ( -
-
- - - - -
+
+ + + +
); } diff --git a/src/app/(service)/insights/page.tsx b/src/app/(service)/insights/page.tsx index c996bc7c..30799732 100644 --- a/src/app/(service)/insights/page.tsx +++ b/src/app/(service)/insights/page.tsx @@ -92,6 +92,17 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { {category.name} ))} + + + 위클리 + + NEW + + +
{/* 아티클 목록 */} diff --git a/src/app/(service)/insights/weekly/[id]/page.tsx b/src/app/(service)/insights/weekly/[id]/page.tsx new file mode 100644 index 00000000..24c45459 --- /dev/null +++ b/src/app/(service)/insights/weekly/[id]/page.tsx @@ -0,0 +1,12 @@ +import VotingDetailPageClient from '@/features/study/one-to-one/balance-game/ui/voting-detail-page-client'; + +export default async function VotingDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const votingId = Number(id); + + return ; +} diff --git a/src/app/(service)/insights/weekly/page.tsx b/src/app/(service)/insights/weekly/page.tsx new file mode 100644 index 00000000..0b380169 --- /dev/null +++ b/src/app/(service)/insights/weekly/page.tsx @@ -0,0 +1,5 @@ +import BalanceGamePage from '@/features/study/one-to-one/balance-game/ui/balance-game-page'; + +export default function VotingPage() { + return ; +} diff --git a/src/app/(service)/layout.tsx b/src/app/(service)/layout.tsx index 8954c45e..41b1a551 100644 --- a/src/app/(service)/layout.tsx +++ b/src/app/(service)/layout.tsx @@ -42,9 +42,9 @@ export default function ServiceLayout({ -
+
-
{children}
+
{children}
diff --git a/src/app/(service)/one-on-one/page.tsx b/src/app/(service)/one-on-one/page.tsx new file mode 100644 index 00000000..8facbe95 --- /dev/null +++ b/src/app/(service)/one-on-one/page.tsx @@ -0,0 +1,5 @@ +import OneOnOnePage from '@/features/study/one-to-one/ui/one-on-one-page'; + +export default function OneOnOnePageRoute() { + return ; +} diff --git a/src/components/card/discussion-card.tsx b/src/components/card/discussion-card.tsx new file mode 100644 index 00000000..b16283b1 --- /dev/null +++ b/src/components/card/discussion-card.tsx @@ -0,0 +1,135 @@ +import { formatDistanceToNow } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { MessageCircle, ThumbsUp, ThumbsDown, Eye, Clock } from 'lucide-react'; +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import { TOPIC_LABELS } from '@/mocks/discussion-mock-data'; +import { Discussion } from '@/types/discussion'; + +interface DiscussionCardProps { + discussion: Discussion; + onClick?: () => void; +} + +export default function DiscussionCard({ + discussion, + onClick, +}: DiscussionCardProps) { + const timeAgo = formatDistanceToNow(new Date(discussion.lastActivityAt), { + addSuffix: true, + locale: ko, + }); + + return ( +
+ {/* 헤더: 작성자 정보 & 주제 */} +
+
+ {/* 아바타 & 닉네임 */} +
e.stopPropagation()}> + +
+ +
+ + {discussion.author.nickname} + +
+ } + /> +
+ + {/* 시간 */} +
+ +
+ + {timeAgo} +
+
+
+ + {/* 주제 배지 */} +
+ {TOPIC_LABELS[discussion.topic]} +
+
+ + {/* 제목 */} +

+ {discussion.title} +

+ + {/* 요약 */} +

+ {discussion.summary} +

+ + {/* 태그 */} + {discussion.tags.length > 0 && ( +
+ {discussion.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* 하단 메타 정보 */} +
+
+ {/* 찬성 */} +
+ + {discussion.vote.agreeCount} +
+ + {/* 반대 */} +
+ + {discussion.vote.disagreeCount} +
+ + {/* 댓글 */} +
+ + {discussion.commentCount} +
+ + {/* 조회수 */} +
+ + {discussion.viewCount.toLocaleString()} +
+
+
+
+ ); +} diff --git a/src/components/card/voting-card.tsx b/src/components/card/voting-card.tsx new file mode 100644 index 00000000..81b2b836 --- /dev/null +++ b/src/components/card/voting-card.tsx @@ -0,0 +1,133 @@ +import { MessageCircle, Users } from 'lucide-react'; +import Link from 'next/link'; +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import { BalanceGame } from '@/types/balance-game'; +import VoteTimer from '../voting/vote-timer'; + +interface VotingCardProps { + voting: BalanceGame; + onClick?: () => void; +} + +export default function VotingCard({ voting, onClick }: VotingCardProps) { + const topOption = voting.options.reduce((prev, current) => + prev.percentage > current.percentage ? prev : current, + ); + + // myVote can be null or number (optionId) + const hasVoted = voting.myVote !== undefined && voting.myVote !== null; + + const cardContent = ( +
{ + e.preventDefault(); + onClick(); + } + : undefined + } + > + {/* 헤더: 작성자 & 상태 */} +
+ {/* 작성자 정보 */} +
e.stopPropagation()}> + +
+ +
+ + {voting.author.nickname} + +
+ } + /> +
+ + {/* 타이머 표시 */} + +
+ + {/* 제목 */} +

+ {voting.title} +

+ + {/* 태그 - 제목 바로 아래에 표시 */} + {voting.tags && Array.isArray(voting.tags) && voting.tags.length > 0 && ( +
+ {voting.tags.map((tag, index) => ( + + #{tag} + + ))} +
+ )} + + {/* 설명 (있으면) */} + {voting.description && ( +

+ {voting.description} +

+ )} + + {/* 간단한 투표 결과 미리보기 (투표했을 때만) */} + {hasVoted && ( +
+
+ 현재 1위 +
+
+ + {topOption.label} + + + {topOption.percentage.toFixed(1)}% + +
+
+ )} + + {/* 하단 메타 정보 */} +
+ {/* 총 투표 수 */} +
+ + {voting.totalVotes.toLocaleString()} +
+ + {/* 댓글 수 */} +
+ + {voting.commentCount || 0} +
+
+ + ); + + // onClick이 있으면 Link 없이 렌더링, 없으면 Link로 감싸기 + if (onClick) { + return cardContent; + } + + return {cardContent}; +} diff --git a/src/components/discussion/comment-form.tsx b/src/components/discussion/comment-form.tsx new file mode 100644 index 00000000..df2f9271 --- /dev/null +++ b/src/components/discussion/comment-form.tsx @@ -0,0 +1,104 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Send, Loader2 } from 'lucide-react'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { CommentFormSchema, CommentFormData } from '@/types/schemas/zod-schema'; + +interface CommentFormProps { + onSubmit: (data: CommentFormData) => void | Promise; + isSubmitting?: boolean; + placeholder?: string; + autoFocus?: boolean; + initialValue?: string; + onCancel?: () => void; +} + +export default function CommentForm({ + onSubmit, + isSubmitting = false, + placeholder = '댓글을 입력하세요...', + autoFocus = false, + initialValue = '', + onCancel, +}: CommentFormProps) { + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(CommentFormSchema), + defaultValues: { + content: initialValue, + }, + }); + + const handleFormSubmit = async (data: CommentFormData) => { + await onSubmit(data); + reset(); + }; + + return ( +
+
+
+